@cluesmith/codev 1.6.2 → 2.0.0-rc.1
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/porch.js +7 -0
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +23 -0
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/index.d.ts +1 -0
- package/dist/agent-farm/commands/index.d.ts.map +1 -1
- package/dist/agent-farm/commands/index.js +1 -0
- package/dist/agent-farm/commands/index.js.map +1 -1
- package/dist/agent-farm/commands/kickoff.d.ts +19 -0
- package/dist/agent-farm/commands/kickoff.d.ts.map +1 -0
- package/dist/agent-farm/commands/kickoff.js +269 -0
- package/dist/agent-farm/commands/kickoff.js.map +1 -0
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +1 -43
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +29 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/pcheck/cache.d.ts +48 -0
- package/dist/commands/pcheck/cache.d.ts.map +1 -0
- package/dist/commands/pcheck/cache.js +170 -0
- package/dist/commands/pcheck/cache.js.map +1 -0
- package/dist/commands/pcheck/evaluator.d.ts +15 -0
- package/dist/commands/pcheck/evaluator.d.ts.map +1 -0
- package/dist/commands/pcheck/evaluator.js +246 -0
- package/dist/commands/pcheck/evaluator.js.map +1 -0
- package/dist/commands/pcheck/index.d.ts +12 -0
- package/dist/commands/pcheck/index.d.ts.map +1 -0
- package/dist/commands/pcheck/index.js +249 -0
- package/dist/commands/pcheck/index.js.map +1 -0
- package/dist/commands/pcheck/parser.d.ts +39 -0
- package/dist/commands/pcheck/parser.d.ts.map +1 -0
- package/dist/commands/pcheck/parser.js +155 -0
- package/dist/commands/pcheck/parser.js.map +1 -0
- package/dist/commands/pcheck/types.d.ts +82 -0
- package/dist/commands/pcheck/types.d.ts.map +1 -0
- package/dist/commands/pcheck/types.js +5 -0
- package/dist/commands/pcheck/types.js.map +1 -0
- package/dist/commands/porch/checks.d.ts +42 -0
- package/dist/commands/porch/checks.d.ts.map +1 -0
- package/dist/commands/porch/checks.js +195 -0
- package/dist/commands/porch/checks.js.map +1 -0
- package/dist/commands/porch/consultation.d.ts +56 -0
- package/dist/commands/porch/consultation.d.ts.map +1 -0
- package/dist/commands/porch/consultation.js +330 -0
- package/dist/commands/porch/consultation.js.map +1 -0
- package/dist/commands/porch/index.d.ts +60 -0
- package/dist/commands/porch/index.d.ts.map +1 -0
- package/dist/commands/porch/index.js +828 -0
- package/dist/commands/porch/index.js.map +1 -0
- package/dist/commands/porch/notifications.d.ts +99 -0
- package/dist/commands/porch/notifications.d.ts.map +1 -0
- package/dist/commands/porch/notifications.js +223 -0
- package/dist/commands/porch/notifications.js.map +1 -0
- package/dist/commands/porch/plan-parser.d.ts +38 -0
- package/dist/commands/porch/plan-parser.d.ts.map +1 -0
- package/dist/commands/porch/plan-parser.js +166 -0
- package/dist/commands/porch/plan-parser.js.map +1 -0
- package/dist/commands/porch/protocol-loader.d.ts +46 -0
- package/dist/commands/porch/protocol-loader.d.ts.map +1 -0
- package/dist/commands/porch/protocol-loader.js +249 -0
- package/dist/commands/porch/protocol-loader.js.map +1 -0
- package/dist/commands/porch/signal-parser.d.ts +88 -0
- package/dist/commands/porch/signal-parser.d.ts.map +1 -0
- package/dist/commands/porch/signal-parser.js +148 -0
- package/dist/commands/porch/signal-parser.js.map +1 -0
- package/dist/commands/porch/state.d.ts +133 -0
- package/dist/commands/porch/state.d.ts.map +1 -0
- package/dist/commands/porch/state.js +760 -0
- package/dist/commands/porch/state.js.map +1 -0
- package/dist/commands/porch/types.d.ts +232 -0
- package/dist/commands/porch/types.d.ts.map +1 -0
- package/dist/commands/porch/types.js +7 -0
- package/dist/commands/porch/types.js.map +1 -0
- package/package.json +2 -1
- package/skeleton/porch/prompts/defend.md +103 -0
- package/skeleton/porch/prompts/diagnose.md +70 -0
- package/skeleton/porch/prompts/evaluate.md +132 -0
- package/skeleton/porch/prompts/fix.md +59 -0
- package/skeleton/porch/prompts/implement.md +79 -0
- package/skeleton/porch/prompts/plan.md +74 -0
- package/skeleton/porch/prompts/pr.md +84 -0
- package/skeleton/porch/prompts/review.md +179 -0
- package/skeleton/porch/prompts/specify.md +53 -0
- package/skeleton/porch/prompts/test.md +63 -0
- package/skeleton/porch/prompts/understand.md +61 -0
- package/skeleton/porch/prompts/verify.md +58 -0
- package/skeleton/porch/protocols/bugfix.json +85 -0
- package/skeleton/porch/protocols/spider.json +135 -0
- package/skeleton/porch/protocols/tick.json +76 -0
- package/skeleton/protocols/bugfix/protocol.json +127 -0
- package/skeleton/protocols/protocol-schema.json +237 -0
- package/skeleton/protocols/spider/protocol.json +204 -0
- package/skeleton/protocols/tick/protocol.json +151 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Porch - Protocol Orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Generic loop orchestrator that reads protocol definitions from JSON
|
|
5
|
+
* and executes them with Claude. Implements the Ralph pattern: fresh
|
|
6
|
+
* context per iteration with state persisted to files.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { findProjectRoot, getSkeletonDir } from '../../lib/skeleton.js';
|
|
13
|
+
import { getProjectDir, readState, writeState, createInitialState, updateState, approveGate, requestGateApproval, updatePhaseStatus, setPlanPhases, findProjects, findExecutions, findStatusFile, findPendingGates, getConsultationAttempts, incrementConsultationAttempts, resetConsultationAttempts, } from './state.js';
|
|
14
|
+
import { extractPhasesFromPlanFile, findPlanFile, } from './plan-parser.js';
|
|
15
|
+
import { extractSignal } from './signal-parser.js';
|
|
16
|
+
import { runPhaseChecks, formatCheckResults } from './checks.js';
|
|
17
|
+
import { runConsultationLoop, formatConsultationResults, hasConsultation, } from './consultation.js';
|
|
18
|
+
import { loadProtocol as loadProtocolFromLoader, listProtocols as listProtocolsFromLoader, } from './protocol-loader.js';
|
|
19
|
+
import { createNotifier, } from './notifications.js';
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Protocol Loading (delegates to protocol-loader.ts)
|
|
22
|
+
// ============================================================================
|
|
23
|
+
/**
|
|
24
|
+
* List available protocols
|
|
25
|
+
* Delegates to protocol-loader.ts for proper conversion
|
|
26
|
+
*/
|
|
27
|
+
export function listProtocols(projectRoot) {
|
|
28
|
+
const root = projectRoot || findProjectRoot();
|
|
29
|
+
return listProtocolsFromLoader(root);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Load a protocol definition
|
|
33
|
+
* Delegates to protocol-loader.ts which properly converts steps→substates
|
|
34
|
+
*/
|
|
35
|
+
export function loadProtocol(name, projectRoot) {
|
|
36
|
+
const root = projectRoot || findProjectRoot();
|
|
37
|
+
const protocol = loadProtocolFromLoader(root, name);
|
|
38
|
+
if (!protocol) {
|
|
39
|
+
throw new Error(`Protocol not found: ${name}\nAvailable protocols: ${listProtocols(root).join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
return protocol;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load a prompt file for a phase
|
|
45
|
+
*/
|
|
46
|
+
function loadPrompt(protocol, phaseId, projectRoot) {
|
|
47
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
48
|
+
if (!phase?.prompt) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
// New structure: protocols/<protocol>/prompts/<prompt>.md
|
|
52
|
+
const promptPaths = [
|
|
53
|
+
path.join(projectRoot, 'codev', 'protocols', protocol.name, 'prompts', phase.prompt),
|
|
54
|
+
path.join(getSkeletonDir(), 'protocols', protocol.name, 'prompts', phase.prompt),
|
|
55
|
+
// Legacy paths
|
|
56
|
+
path.join(projectRoot, 'codev', 'porch', 'prompts', phase.prompt),
|
|
57
|
+
path.join(getSkeletonDir(), 'porch', 'prompts', phase.prompt),
|
|
58
|
+
];
|
|
59
|
+
for (const promptPath of promptPaths) {
|
|
60
|
+
if (fs.existsSync(promptPath)) {
|
|
61
|
+
return fs.readFileSync(promptPath, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
// Try with .md extension
|
|
64
|
+
if (fs.existsSync(`${promptPath}.md`)) {
|
|
65
|
+
return fs.readFileSync(`${promptPath}.md`, 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Protocol Helpers
|
|
72
|
+
// ============================================================================
|
|
73
|
+
/**
|
|
74
|
+
* Check if a phase is terminal
|
|
75
|
+
*/
|
|
76
|
+
function isTerminalPhase(protocol, phaseId) {
|
|
77
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
78
|
+
return phase?.terminal === true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Find the phase that has a gate blocking after the given state
|
|
82
|
+
*/
|
|
83
|
+
function getGateForState(protocol, state) {
|
|
84
|
+
const [phaseId, substate] = state.split(':');
|
|
85
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
86
|
+
if (phase?.gate && phase.gate.after === substate) {
|
|
87
|
+
const gateId = `${phaseId}_approval`;
|
|
88
|
+
return { gateId, phase };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get next state after gate passes
|
|
94
|
+
*/
|
|
95
|
+
function getGateNextState(protocol, phaseId) {
|
|
96
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
97
|
+
return phase?.gate?.next || null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get signal-based next state
|
|
101
|
+
*/
|
|
102
|
+
function getSignalNextState(protocol, phaseId, signal) {
|
|
103
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
104
|
+
return phase?.signals?.[signal] || null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get the default next state for a phase (first substate or next phase)
|
|
108
|
+
*/
|
|
109
|
+
function getDefaultNextState(protocol, state) {
|
|
110
|
+
const [phaseId, substate] = state.split(':');
|
|
111
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
112
|
+
if (!phase)
|
|
113
|
+
return null;
|
|
114
|
+
// If phase has substates, move to next substate
|
|
115
|
+
if (phase.substates && substate) {
|
|
116
|
+
const currentIdx = phase.substates.indexOf(substate);
|
|
117
|
+
if (currentIdx >= 0 && currentIdx < phase.substates.length - 1) {
|
|
118
|
+
return `${phaseId}:${phase.substates[currentIdx + 1]}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Move to next phase
|
|
122
|
+
const phaseIdx = protocol.phases.findIndex(p => p.id === phaseId);
|
|
123
|
+
if (phaseIdx >= 0 && phaseIdx < protocol.phases.length - 1) {
|
|
124
|
+
const nextPhase = protocol.phases[phaseIdx + 1];
|
|
125
|
+
if (nextPhase.substates && nextPhase.substates.length > 0) {
|
|
126
|
+
return `${nextPhase.id}:${nextPhase.substates[0]}`;
|
|
127
|
+
}
|
|
128
|
+
return nextPhase.id;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// Claude Invocation
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// Note: extractSignal is now imported from signal-parser.js
|
|
136
|
+
/**
|
|
137
|
+
* Invoke Claude for a phase
|
|
138
|
+
*/
|
|
139
|
+
async function invokeClaude(protocol, phaseId, state, statusFilePath, projectRoot, options) {
|
|
140
|
+
const promptContent = loadPrompt(protocol, phaseId, projectRoot);
|
|
141
|
+
if (!promptContent) {
|
|
142
|
+
console.log(chalk.yellow(`[porch] No prompt file for phase: ${phaseId}`));
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
if (options.dryRun) {
|
|
146
|
+
console.log(chalk.yellow(`[porch] [DRY RUN] Would invoke Claude for phase: ${phaseId}`));
|
|
147
|
+
return '';
|
|
148
|
+
}
|
|
149
|
+
if (options.noClaude) {
|
|
150
|
+
console.log(chalk.blue(`[porch] [NO_CLAUDE] Simulating phase: ${phaseId}`));
|
|
151
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
152
|
+
console.log(chalk.green(`[porch] Simulated completion of phase: ${phaseId}`));
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
console.log(chalk.cyan(`[phase] Invoking Claude for phase: ${phaseId}`));
|
|
156
|
+
const timeout = protocol.config?.claude_timeout || 600000; // 10 minutes default
|
|
157
|
+
const fullPrompt = `## Protocol: ${protocol.name}
|
|
158
|
+
## Phase: ${phaseId}
|
|
159
|
+
## Project ID: ${state.id}
|
|
160
|
+
|
|
161
|
+
## Current Status
|
|
162
|
+
\`\`\`yaml
|
|
163
|
+
state: "${state.current_state}"
|
|
164
|
+
iteration: ${state.iteration}
|
|
165
|
+
started_at: "${state.started_at}"
|
|
166
|
+
\`\`\`
|
|
167
|
+
|
|
168
|
+
## Task
|
|
169
|
+
Execute the ${phaseId} phase for project ${state.id} - ${state.title}
|
|
170
|
+
|
|
171
|
+
## Phase Instructions
|
|
172
|
+
${promptContent}
|
|
173
|
+
|
|
174
|
+
## Important
|
|
175
|
+
- Project ID: ${state.id}
|
|
176
|
+
- Protocol: ${protocol.name}
|
|
177
|
+
- Follow the instructions above precisely
|
|
178
|
+
- Output <signal>...</signal> tags when you reach completion points
|
|
179
|
+
`;
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const args = ['--print', '-p', fullPrompt, '--dangerously-skip-permissions'];
|
|
182
|
+
const proc = spawn('claude', args, {
|
|
183
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
184
|
+
timeout,
|
|
185
|
+
});
|
|
186
|
+
let output = '';
|
|
187
|
+
let stderr = '';
|
|
188
|
+
proc.stdout.on('data', (data) => {
|
|
189
|
+
output += data.toString();
|
|
190
|
+
process.stdout.write(data);
|
|
191
|
+
});
|
|
192
|
+
proc.stderr.on('data', (data) => {
|
|
193
|
+
stderr += data.toString();
|
|
194
|
+
process.stderr.write(data);
|
|
195
|
+
});
|
|
196
|
+
proc.on('close', (code) => {
|
|
197
|
+
if (code !== 0) {
|
|
198
|
+
reject(new Error(`Claude exited with code ${code}\n${stderr}`));
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
resolve(output);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
proc.on('error', reject);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// ============================================================================
|
|
208
|
+
// Commands
|
|
209
|
+
// ============================================================================
|
|
210
|
+
/**
|
|
211
|
+
* Initialize a new project with a protocol
|
|
212
|
+
*/
|
|
213
|
+
export async function init(protocolName, projectId, projectName, options = {}) {
|
|
214
|
+
const projectRoot = findProjectRoot();
|
|
215
|
+
const protocol = loadProtocol(protocolName, projectRoot);
|
|
216
|
+
// Create project directory
|
|
217
|
+
const projectDir = getProjectDir(projectRoot, projectId, projectName);
|
|
218
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
219
|
+
// Create initial state
|
|
220
|
+
const state = createInitialState(protocol, projectId, projectName, options.worktree);
|
|
221
|
+
const statusPath = path.join(projectDir, 'status.yaml');
|
|
222
|
+
await writeState(statusPath, state);
|
|
223
|
+
console.log(chalk.green(`[porch] Initialized project ${projectId} with protocol ${protocolName}`));
|
|
224
|
+
console.log(chalk.blue(`[porch] Project directory: ${projectDir}`));
|
|
225
|
+
console.log(chalk.blue(`[porch] Initial state: ${state.current_state}`));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Check if a protocol phase is a "phased" phase (runs per plan-phase)
|
|
229
|
+
*/
|
|
230
|
+
function isPhasedPhase(protocol, phaseId) {
|
|
231
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
232
|
+
return phase?.phased === true;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Get the IDE phases (implement, defend, evaluate) that run per plan-phase
|
|
236
|
+
*/
|
|
237
|
+
function getIDEPhases(protocol) {
|
|
238
|
+
return protocol.phases
|
|
239
|
+
.filter(p => p.phased === true)
|
|
240
|
+
.map(p => p.id);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Parse the current plan-phase from state like "implement:phase_1"
|
|
244
|
+
*/
|
|
245
|
+
function parsePlanPhaseFromState(state) {
|
|
246
|
+
const parts = state.split(':');
|
|
247
|
+
const phaseId = parts[0];
|
|
248
|
+
// Check if second part is a plan phase (phase_N) or a substate
|
|
249
|
+
if (parts.length > 1) {
|
|
250
|
+
if (parts[1].startsWith('phase_')) {
|
|
251
|
+
return { phaseId, planPhaseId: parts[1], substate: parts[2] || null };
|
|
252
|
+
}
|
|
253
|
+
return { phaseId, planPhaseId: null, substate: parts[1] };
|
|
254
|
+
}
|
|
255
|
+
return { phaseId, planPhaseId: null, substate: null };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Get the next IDE state for a phased phase
|
|
259
|
+
* implement:phase_1 → defend:phase_1 → evaluate:phase_1 → implement:phase_2 → ...
|
|
260
|
+
*/
|
|
261
|
+
function getNextIDEState(protocol, currentState, planPhases, signal) {
|
|
262
|
+
const { phaseId, planPhaseId } = parsePlanPhaseFromState(currentState);
|
|
263
|
+
if (!planPhaseId)
|
|
264
|
+
return null;
|
|
265
|
+
const idePhases = getIDEPhases(protocol);
|
|
266
|
+
const currentIdeIndex = idePhases.indexOf(phaseId);
|
|
267
|
+
if (currentIdeIndex < 0)
|
|
268
|
+
return null;
|
|
269
|
+
// If not at the end of IDE phases, move to next IDE phase for same plan-phase
|
|
270
|
+
if (currentIdeIndex < idePhases.length - 1) {
|
|
271
|
+
return `${idePhases[currentIdeIndex + 1]}:${planPhaseId}`;
|
|
272
|
+
}
|
|
273
|
+
// At end of IDE phases (evaluate), move to next plan-phase
|
|
274
|
+
const currentPlanIndex = planPhases.findIndex(p => p.id === planPhaseId);
|
|
275
|
+
if (currentPlanIndex < 0)
|
|
276
|
+
return null;
|
|
277
|
+
// Check if there's a next plan phase
|
|
278
|
+
if (currentPlanIndex < planPhases.length - 1) {
|
|
279
|
+
const nextPlanPhase = planPhases[currentPlanIndex + 1];
|
|
280
|
+
return `${idePhases[0]}:${nextPlanPhase.id}`; // Start implement for next phase
|
|
281
|
+
}
|
|
282
|
+
// All plan phases complete, move to review
|
|
283
|
+
const reviewPhase = protocol.phases.find(p => p.id === 'review');
|
|
284
|
+
if (reviewPhase) {
|
|
285
|
+
return 'review';
|
|
286
|
+
}
|
|
287
|
+
return 'complete';
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Run the protocol loop for a project
|
|
291
|
+
*/
|
|
292
|
+
export async function run(projectId, options = {}) {
|
|
293
|
+
const projectRoot = findProjectRoot();
|
|
294
|
+
// Find status file
|
|
295
|
+
const statusFilePath = findStatusFile(projectRoot, projectId);
|
|
296
|
+
if (!statusFilePath) {
|
|
297
|
+
throw new Error(`Status file not found for project: ${projectId}\n` +
|
|
298
|
+
`Run: porch init <protocol> ${projectId} <project-name>`);
|
|
299
|
+
}
|
|
300
|
+
// Read state and load protocol
|
|
301
|
+
const state = readState(statusFilePath);
|
|
302
|
+
if (!state) {
|
|
303
|
+
throw new Error(`Could not read state from: ${statusFilePath}`);
|
|
304
|
+
}
|
|
305
|
+
const protocol = loadProtocol(state.protocol, projectRoot);
|
|
306
|
+
const pollInterval = options.pollInterval || protocol.config?.poll_interval || 30;
|
|
307
|
+
const maxIterations = protocol.config?.max_iterations || 100;
|
|
308
|
+
// Create notifier for this project (desktop notifications for important events)
|
|
309
|
+
const notifier = createNotifier(projectId, { desktop: true });
|
|
310
|
+
console.log(chalk.blue(`[porch] Starting ${state.protocol} loop for project ${projectId}`));
|
|
311
|
+
console.log(chalk.blue(`[porch] Status file: ${statusFilePath}`));
|
|
312
|
+
console.log(chalk.blue(`[porch] Poll interval: ${pollInterval}s`));
|
|
313
|
+
let currentState = state;
|
|
314
|
+
// Extract plan phases if not already done and we're past planning
|
|
315
|
+
if (!currentState.plan_phases || currentState.plan_phases.length === 0) {
|
|
316
|
+
const planFile = findPlanFile(projectRoot, projectId, currentState.title);
|
|
317
|
+
if (planFile) {
|
|
318
|
+
try {
|
|
319
|
+
const planPhases = extractPhasesFromPlanFile(planFile);
|
|
320
|
+
currentState = setPlanPhases(currentState, planPhases);
|
|
321
|
+
await writeState(statusFilePath, currentState);
|
|
322
|
+
console.log(chalk.blue(`[porch] Extracted ${planPhases.length} phases from plan`));
|
|
323
|
+
for (const phase of planPhases) {
|
|
324
|
+
console.log(chalk.blue(` - ${phase.id}: ${phase.title}`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
console.log(chalk.yellow(`[porch] Could not extract plan phases: ${e}`));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (let iteration = currentState.iteration; iteration <= maxIterations; iteration++) {
|
|
333
|
+
console.log(chalk.blue('━'.repeat(40)));
|
|
334
|
+
console.log(chalk.blue(`[porch] Iteration ${iteration}`));
|
|
335
|
+
console.log(chalk.blue('━'.repeat(40)));
|
|
336
|
+
// Fresh read of state each iteration (Ralph pattern)
|
|
337
|
+
currentState = readState(statusFilePath) || currentState;
|
|
338
|
+
console.log(chalk.blue(`[porch] Current state: ${currentState.current_state}`));
|
|
339
|
+
// Parse state into phase and substate
|
|
340
|
+
const { phaseId, planPhaseId, substate } = parsePlanPhaseFromState(currentState.current_state);
|
|
341
|
+
// Re-attempt plan phase extraction if entering a phased phase without plan phases
|
|
342
|
+
// This handles the case where porch started during Specify phase (no plan file yet)
|
|
343
|
+
if (isPhasedPhase(protocol, phaseId) && (!currentState.plan_phases || currentState.plan_phases.length === 0)) {
|
|
344
|
+
const planFile = findPlanFile(projectRoot, projectId, currentState.title);
|
|
345
|
+
if (planFile) {
|
|
346
|
+
try {
|
|
347
|
+
const planPhases = extractPhasesFromPlanFile(planFile);
|
|
348
|
+
currentState = setPlanPhases(currentState, planPhases);
|
|
349
|
+
await writeState(statusFilePath, currentState);
|
|
350
|
+
console.log(chalk.blue(`[porch] Late discovery: Extracted ${planPhases.length} phases from plan`));
|
|
351
|
+
for (const phase of planPhases) {
|
|
352
|
+
console.log(chalk.blue(` - ${phase.id}: ${phase.title}`));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
console.log(chalk.yellow(`[porch] Could not extract plan phases: ${e}`));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
console.log(chalk.yellow(`[porch] Warning: Entering phased phase '${phaseId}' but no plan file found`));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Check if terminal phase
|
|
364
|
+
if (isTerminalPhase(protocol, phaseId)) {
|
|
365
|
+
console.log(chalk.green('━'.repeat(40)));
|
|
366
|
+
console.log(chalk.green(`[porch] ${state.protocol} loop COMPLETE`));
|
|
367
|
+
console.log(chalk.green(`[porch] Project ${projectId} finished all phases`));
|
|
368
|
+
console.log(chalk.green('━'.repeat(40)));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Check if there's a pending gate from a previous iteration (step already executed)
|
|
372
|
+
const pendingGateInfo = getGateForState(protocol, currentState.current_state);
|
|
373
|
+
if (pendingGateInfo) {
|
|
374
|
+
const { gateId } = pendingGateInfo;
|
|
375
|
+
// Check if gate is already approved
|
|
376
|
+
if (currentState.gates[gateId]?.status === 'passed') {
|
|
377
|
+
// Gate approved - proceed to next state
|
|
378
|
+
const nextState = getGateNextState(protocol, phaseId);
|
|
379
|
+
if (nextState) {
|
|
380
|
+
console.log(chalk.green(`[porch] Gate ${gateId} passed! Proceeding to ${nextState}`));
|
|
381
|
+
await notifier.gateApproved(gateId);
|
|
382
|
+
// Reset any consultation attempts for the gated state
|
|
383
|
+
currentState = resetConsultationAttempts(currentState, currentState.current_state);
|
|
384
|
+
// If entering a phased phase, start with first plan phase
|
|
385
|
+
if (isPhasedPhase(protocol, nextState.split(':')[0]) && currentState.plan_phases?.length) {
|
|
386
|
+
const firstPlanPhase = currentState.plan_phases[0];
|
|
387
|
+
currentState = updateState(currentState, `${nextState.split(':')[0]}:${firstPlanPhase.id}`);
|
|
388
|
+
currentState = updatePhaseStatus(currentState, firstPlanPhase.id, 'in_progress');
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
currentState = updateState(currentState, nextState);
|
|
392
|
+
}
|
|
393
|
+
await writeState(statusFilePath, currentState);
|
|
394
|
+
continue; // Start next iteration with new state
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
else if (currentState.gates[gateId]?.requested_at) {
|
|
398
|
+
// Gate requested but not approved - wait
|
|
399
|
+
console.log(chalk.cyan(`[phase] Phase: ${phaseId} (waiting for gate: ${gateId})`));
|
|
400
|
+
console.log(chalk.yellow(`[porch] BLOCKED - Waiting for gate: ${gateId}`));
|
|
401
|
+
console.log(chalk.yellow(`[porch] To approve: porch approve ${projectId} ${gateId}`));
|
|
402
|
+
await new Promise(r => setTimeout(r, pollInterval * 1000));
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// If gate not yet requested, fall through to execute phase first
|
|
406
|
+
}
|
|
407
|
+
// Get the current phase definition
|
|
408
|
+
const phase = protocol.phases.find(p => p.id === phaseId);
|
|
409
|
+
// Show plan phase context if in a phased phase
|
|
410
|
+
if (planPhaseId && currentState.plan_phases) {
|
|
411
|
+
const planPhase = currentState.plan_phases.find(p => p.id === planPhaseId);
|
|
412
|
+
if (planPhase) {
|
|
413
|
+
console.log(chalk.cyan(`[phase] IDE Phase: ${phaseId} | Plan Phase: ${planPhase.title}`));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
console.log(chalk.cyan(`[phase] Phase: ${phaseId}`));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
console.log(chalk.cyan(`[phase] Phase: ${phaseId}`));
|
|
421
|
+
}
|
|
422
|
+
// Notify phase start
|
|
423
|
+
await notifier.phaseStart(phaseId);
|
|
424
|
+
// Execute phase
|
|
425
|
+
const output = await invokeClaude(protocol, phaseId, currentState, statusFilePath, projectRoot, options);
|
|
426
|
+
const signal = extractSignal(output);
|
|
427
|
+
// Run phase checks (build/test) if defined
|
|
428
|
+
if (phase?.checks && !options.dryRun) {
|
|
429
|
+
console.log(chalk.blue(`[porch] Running checks for phase ${phaseId}...`));
|
|
430
|
+
const checkResult = await runPhaseChecks(phase, {
|
|
431
|
+
cwd: projectRoot,
|
|
432
|
+
dryRun: options.dryRun,
|
|
433
|
+
});
|
|
434
|
+
console.log(formatCheckResults(checkResult));
|
|
435
|
+
if (!checkResult.success) {
|
|
436
|
+
// Notify about check failure
|
|
437
|
+
const failedCheck = checkResult.checks.find(c => !c.success);
|
|
438
|
+
await notifier.checkFailed(phaseId, failedCheck?.name || 'build/test', failedCheck?.error || 'Check failed');
|
|
439
|
+
// If checks fail, handle based on check configuration
|
|
440
|
+
if (checkResult.returnTo) {
|
|
441
|
+
console.log(chalk.yellow(`[porch] Checks failed, returning to ${checkResult.returnTo}`));
|
|
442
|
+
if (planPhaseId) {
|
|
443
|
+
currentState = updateState(currentState, `${checkResult.returnTo}:${planPhaseId}`);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
currentState = updateState(currentState, checkResult.returnTo);
|
|
447
|
+
}
|
|
448
|
+
await writeState(statusFilePath, currentState);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// No returnTo means we should retry (already handled in checks)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Run consultation if configured for this phase/substate
|
|
455
|
+
if (phase?.consultation && hasConsultation(phase) && !options.dryRun && !options.noClaude) {
|
|
456
|
+
const consultConfig = phase.consultation;
|
|
457
|
+
const currentSubstate = substate || parsePlanPhaseFromState(currentState.current_state).substate;
|
|
458
|
+
// Check if consultation is triggered by current substate
|
|
459
|
+
if (consultConfig.on === currentSubstate || consultConfig.on === phaseId) {
|
|
460
|
+
const maxRounds = consultConfig.max_rounds || 3;
|
|
461
|
+
const stateKey = currentState.current_state;
|
|
462
|
+
// Get attempt count from state (persisted across porch iterations)
|
|
463
|
+
const attemptCount = getConsultationAttempts(currentState, stateKey) + 1;
|
|
464
|
+
console.log(chalk.blue(`[porch] Consultation triggered for phase ${phaseId} (attempt ${attemptCount}/${maxRounds})`));
|
|
465
|
+
await notifier.consultationStart(phaseId, consultConfig.models || ['gemini', 'codex', 'claude']);
|
|
466
|
+
const consultResult = await runConsultationLoop(consultConfig, {
|
|
467
|
+
subcommand: consultConfig.type.includes('pr') ? 'pr' : consultConfig.type.includes('spec') ? 'spec' : 'plan',
|
|
468
|
+
identifier: projectId,
|
|
469
|
+
cwd: projectRoot,
|
|
470
|
+
timeout: protocol.config?.consultation_timeout,
|
|
471
|
+
dryRun: options.dryRun,
|
|
472
|
+
});
|
|
473
|
+
console.log(formatConsultationResults(consultResult));
|
|
474
|
+
await notifier.consultationComplete(phaseId, consultResult.feedback, consultResult.allApproved);
|
|
475
|
+
// If not all approved, track attempt and check for escalation
|
|
476
|
+
if (!consultResult.allApproved) {
|
|
477
|
+
// Increment attempt count in state (persists across iterations)
|
|
478
|
+
currentState = incrementConsultationAttempts(currentState, stateKey);
|
|
479
|
+
await writeState(statusFilePath, currentState);
|
|
480
|
+
// Check if we've reached max attempts
|
|
481
|
+
if (attemptCount >= maxRounds) {
|
|
482
|
+
// Create escalation gate - requires human intervention
|
|
483
|
+
const escalationGateId = `${phaseId}_consultation_escalation`;
|
|
484
|
+
// Check if escalation gate was already approved (human override)
|
|
485
|
+
if (currentState.gates[escalationGateId]?.status === 'passed') {
|
|
486
|
+
console.log(chalk.green(`[porch] Consultation escalation gate already approved, continuing`));
|
|
487
|
+
// Reset attempts and fall through to next state handling
|
|
488
|
+
currentState = resetConsultationAttempts(currentState, stateKey);
|
|
489
|
+
await writeState(statusFilePath, currentState);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
console.log(chalk.red(`[porch] Consultation failed after ${attemptCount} attempts - escalating to human`));
|
|
493
|
+
console.log(chalk.yellow(`[porch] To override and continue: porch approve ${projectId} ${escalationGateId}`));
|
|
494
|
+
// Request human gate if not already requested
|
|
495
|
+
if (!currentState.gates[escalationGateId]?.requested_at) {
|
|
496
|
+
currentState = requestGateApproval(currentState, escalationGateId);
|
|
497
|
+
await writeState(statusFilePath, currentState);
|
|
498
|
+
await notifier.gatePending(phaseId, escalationGateId);
|
|
499
|
+
}
|
|
500
|
+
// Wait for human approval
|
|
501
|
+
await new Promise(r => setTimeout(r, pollInterval * 1000));
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.log(chalk.yellow(`[porch] Consultation requested changes (attempt ${attemptCount}/${maxRounds}), continuing for revision`));
|
|
507
|
+
// Stay in same state for Claude to revise on next iteration
|
|
508
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
// All approved - reset attempt counter
|
|
514
|
+
currentState = resetConsultationAttempts(currentState, stateKey);
|
|
515
|
+
await writeState(statusFilePath, currentState);
|
|
516
|
+
}
|
|
517
|
+
// All approved (or escalation gate passed) - use consultation's next state if defined
|
|
518
|
+
if (consultConfig.next) {
|
|
519
|
+
if (planPhaseId) {
|
|
520
|
+
currentState = updateState(currentState, `${consultConfig.next}:${planPhaseId}`);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
currentState = updateState(currentState, consultConfig.next);
|
|
524
|
+
}
|
|
525
|
+
await writeState(statusFilePath, currentState);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// Check if current state has a gate that should trigger AFTER this step
|
|
531
|
+
// This is the gate trigger point - step has executed, now block before transition
|
|
532
|
+
const gateInfo = getGateForState(protocol, currentState.current_state);
|
|
533
|
+
if (gateInfo) {
|
|
534
|
+
const { gateId } = gateInfo;
|
|
535
|
+
// Request gate approval if not already requested
|
|
536
|
+
if (!currentState.gates[gateId]?.requested_at) {
|
|
537
|
+
currentState = requestGateApproval(currentState, gateId);
|
|
538
|
+
await writeState(statusFilePath, currentState);
|
|
539
|
+
console.log(chalk.yellow(`[porch] Step complete. Gate approval requested: ${gateId}`));
|
|
540
|
+
await notifier.gatePending(phaseId, gateId);
|
|
541
|
+
}
|
|
542
|
+
// Gate not yet approved - wait (will check approval at start of next iteration)
|
|
543
|
+
if (currentState.gates[gateId]?.status !== 'passed') {
|
|
544
|
+
console.log(chalk.yellow(`[porch] BLOCKED - Waiting for gate: ${gateId}`));
|
|
545
|
+
console.log(chalk.yellow(`[porch] To approve: porch approve ${projectId} ${gateId}`));
|
|
546
|
+
await new Promise(r => setTimeout(r, pollInterval * 1000));
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Determine next state
|
|
551
|
+
let nextState = null;
|
|
552
|
+
if (signal) {
|
|
553
|
+
console.log(chalk.green(`[porch] Signal received: ${signal}`));
|
|
554
|
+
nextState = getSignalNextState(protocol, phaseId, signal);
|
|
555
|
+
}
|
|
556
|
+
if (!nextState) {
|
|
557
|
+
// Check if this is a phased phase (IDE loop)
|
|
558
|
+
if (isPhasedPhase(protocol, phaseId) && currentState.plan_phases?.length) {
|
|
559
|
+
nextState = getNextIDEState(protocol, currentState.current_state, currentState.plan_phases, signal || undefined);
|
|
560
|
+
// Mark current plan phase as complete if moving to next
|
|
561
|
+
if (nextState && planPhaseId) {
|
|
562
|
+
const { planPhaseId: nextPlanPhaseId } = parsePlanPhaseFromState(nextState);
|
|
563
|
+
if (nextPlanPhaseId !== planPhaseId) {
|
|
564
|
+
currentState = updatePhaseStatus(currentState, planPhaseId, 'complete');
|
|
565
|
+
if (nextPlanPhaseId) {
|
|
566
|
+
currentState = updatePhaseStatus(currentState, nextPlanPhaseId, 'in_progress');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
// Use default transition
|
|
573
|
+
nextState = getDefaultNextState(protocol, currentState.current_state);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (nextState) {
|
|
577
|
+
currentState = updateState(currentState, nextState, signal ? { signal } : undefined);
|
|
578
|
+
await writeState(statusFilePath, currentState);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
console.log(chalk.yellow(`[porch] No transition defined, staying in current state`));
|
|
582
|
+
}
|
|
583
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
584
|
+
}
|
|
585
|
+
throw new Error(`Max iterations (${maxIterations}) reached!`);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Approve a gate
|
|
589
|
+
*/
|
|
590
|
+
export async function approve(projectId, gateId) {
|
|
591
|
+
const projectRoot = findProjectRoot();
|
|
592
|
+
const statusFilePath = findStatusFile(projectRoot, projectId);
|
|
593
|
+
if (!statusFilePath) {
|
|
594
|
+
throw new Error(`Status file not found for project: ${projectId}`);
|
|
595
|
+
}
|
|
596
|
+
const state = readState(statusFilePath);
|
|
597
|
+
if (!state) {
|
|
598
|
+
throw new Error(`Could not read state from: ${statusFilePath}`);
|
|
599
|
+
}
|
|
600
|
+
const updatedState = approveGate(state, gateId);
|
|
601
|
+
await writeState(statusFilePath, updatedState);
|
|
602
|
+
console.log(chalk.green(`[porch] Approved: ${gateId}`));
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Show project status
|
|
606
|
+
*/
|
|
607
|
+
export async function status(projectId) {
|
|
608
|
+
const projectRoot = findProjectRoot();
|
|
609
|
+
if (projectId) {
|
|
610
|
+
// Show specific project
|
|
611
|
+
const statusFilePath = findStatusFile(projectRoot, projectId);
|
|
612
|
+
if (!statusFilePath) {
|
|
613
|
+
throw new Error(`Status file not found for project: ${projectId}`);
|
|
614
|
+
}
|
|
615
|
+
const state = readState(statusFilePath);
|
|
616
|
+
if (!state) {
|
|
617
|
+
throw new Error(`Could not read state from: ${statusFilePath}`);
|
|
618
|
+
}
|
|
619
|
+
console.log(chalk.blue(`[porch] Status for project ${projectId}:`));
|
|
620
|
+
console.log('');
|
|
621
|
+
console.log(` ID: ${state.id}`);
|
|
622
|
+
console.log(` Title: ${state.title}`);
|
|
623
|
+
console.log(` Protocol: ${state.protocol}`);
|
|
624
|
+
console.log(` State: ${state.current_state}`);
|
|
625
|
+
console.log(` Iteration: ${state.iteration}`);
|
|
626
|
+
console.log(` Started: ${state.started_at}`);
|
|
627
|
+
console.log(` Updated: ${state.last_updated}`);
|
|
628
|
+
console.log('');
|
|
629
|
+
if (Object.keys(state.gates).length > 0) {
|
|
630
|
+
console.log(' Gates:');
|
|
631
|
+
for (const [gateId, gateStatus] of Object.entries(state.gates)) {
|
|
632
|
+
const icon = gateStatus.status === 'passed' ? '✓' : gateStatus.status === 'failed' ? '✗' : '⏳';
|
|
633
|
+
console.log(` ${icon} ${gateId}: ${gateStatus.status}`);
|
|
634
|
+
}
|
|
635
|
+
console.log('');
|
|
636
|
+
}
|
|
637
|
+
if (state.plan_phases && state.plan_phases.length > 0) {
|
|
638
|
+
console.log(' Plan Phases:');
|
|
639
|
+
for (const phase of state.plan_phases) {
|
|
640
|
+
const phaseStatus = state.phases[phase.id]?.status || 'pending';
|
|
641
|
+
const icon = phaseStatus === 'complete' ? '✓' : phaseStatus === 'in_progress' ? '🔄' : '○';
|
|
642
|
+
console.log(` ${icon} ${phase.id}: ${phase.title}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
// Show all projects
|
|
648
|
+
const projects = findProjects(projectRoot);
|
|
649
|
+
const executions = findExecutions(projectRoot);
|
|
650
|
+
if (projects.length === 0 && executions.length === 0) {
|
|
651
|
+
console.log(chalk.yellow('[porch] No projects found'));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
console.log(chalk.blue('[porch] Projects:'));
|
|
655
|
+
for (const { id, path: statusPath } of projects) {
|
|
656
|
+
const state = readState(statusPath);
|
|
657
|
+
if (state) {
|
|
658
|
+
const pendingGates = Object.entries(state.gates)
|
|
659
|
+
.filter(([, g]) => g.status === 'pending' && g.requested_at)
|
|
660
|
+
.map(([id]) => id);
|
|
661
|
+
const gateStr = pendingGates.length > 0 ? chalk.yellow(` [${pendingGates.join(', ')}]`) : '';
|
|
662
|
+
console.log(` ${id} ${state.title} - ${state.current_state}${gateStr}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (executions.length > 0) {
|
|
666
|
+
console.log('');
|
|
667
|
+
console.log(chalk.blue('[porch] Executions:'));
|
|
668
|
+
for (const { protocol, id, path: statusPath } of executions) {
|
|
669
|
+
const state = readState(statusPath);
|
|
670
|
+
if (state) {
|
|
671
|
+
console.log(` ${protocol}/${id} - ${state.current_state}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* List available protocols
|
|
679
|
+
*/
|
|
680
|
+
export async function list() {
|
|
681
|
+
const projectRoot = findProjectRoot();
|
|
682
|
+
const protocols = listProtocols(projectRoot);
|
|
683
|
+
if (protocols.length === 0) {
|
|
684
|
+
console.log(chalk.yellow('[porch] No protocols found'));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
console.log(chalk.blue('[porch] Available protocols:'));
|
|
688
|
+
for (const name of protocols) {
|
|
689
|
+
try {
|
|
690
|
+
const protocol = loadProtocol(name, projectRoot);
|
|
691
|
+
console.log(` - ${name}: ${protocol.description}`);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
console.log(` - ${name}: (error loading)`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Show protocol definition
|
|
700
|
+
*/
|
|
701
|
+
export async function show(protocolName) {
|
|
702
|
+
const projectRoot = findProjectRoot();
|
|
703
|
+
const protocol = loadProtocol(protocolName, projectRoot);
|
|
704
|
+
console.log(chalk.blue(`[porch] Protocol: ${protocolName}`));
|
|
705
|
+
console.log('');
|
|
706
|
+
console.log(JSON.stringify(protocol, null, 2));
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Show pending gates across all projects
|
|
710
|
+
*/
|
|
711
|
+
export async function pending() {
|
|
712
|
+
const projectRoot = findProjectRoot();
|
|
713
|
+
const gates = findPendingGates(projectRoot);
|
|
714
|
+
if (gates.length === 0) {
|
|
715
|
+
console.log(chalk.green('[porch] No pending gates'));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
console.log(chalk.yellow('[porch] Pending gates:'));
|
|
719
|
+
for (const gate of gates) {
|
|
720
|
+
const requestedAt = gate.requestedAt ? ` (requested ${gate.requestedAt})` : '';
|
|
721
|
+
console.log(` ${gate.projectId}: ${gate.gateId}${requestedAt}`);
|
|
722
|
+
console.log(` → porch approve ${gate.projectId} ${gate.gateId}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// ============================================================================
|
|
726
|
+
// Auto-Detection
|
|
727
|
+
// ============================================================================
|
|
728
|
+
/**
|
|
729
|
+
* Auto-detect project ID from current directory
|
|
730
|
+
*
|
|
731
|
+
* Detection methods:
|
|
732
|
+
* 1. Check if cwd is a worktree matching pattern: .builders/<id> or worktrees/<protocol>_<id>_*
|
|
733
|
+
* 2. Check for a single project in codev/projects/
|
|
734
|
+
* 3. Check for .porch-project marker file
|
|
735
|
+
*/
|
|
736
|
+
function autoDetectProject() {
|
|
737
|
+
const cwd = process.cwd();
|
|
738
|
+
// Method 1: Check path pattern for builder worktree
|
|
739
|
+
// Pattern: .builders/<id> or .builders/<id>-<name>
|
|
740
|
+
const buildersMatch = cwd.match(/[/\\]\.builders[/\\](\d+)(?:-[^/\\]*)?(?:[/\\]|$)/);
|
|
741
|
+
if (buildersMatch) {
|
|
742
|
+
return buildersMatch[1];
|
|
743
|
+
}
|
|
744
|
+
// Pattern: worktrees/<protocol>_<id>_<name>
|
|
745
|
+
const worktreeMatch = cwd.match(/[/\\]worktrees[/\\]\w+_(\d+)_[^/\\]*(?:[/\\]|$)/);
|
|
746
|
+
if (worktreeMatch) {
|
|
747
|
+
return worktreeMatch[1];
|
|
748
|
+
}
|
|
749
|
+
// Method 2: Check for .porch-project marker file
|
|
750
|
+
const markerPath = path.join(cwd, '.porch-project');
|
|
751
|
+
if (fs.existsSync(markerPath)) {
|
|
752
|
+
const content = fs.readFileSync(markerPath, 'utf-8').trim();
|
|
753
|
+
if (content) {
|
|
754
|
+
return content;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Method 3: Check if there's exactly one project in codev/projects/
|
|
758
|
+
try {
|
|
759
|
+
const projectRoot = findProjectRoot();
|
|
760
|
+
const projects = findProjects(projectRoot);
|
|
761
|
+
if (projects.length === 1) {
|
|
762
|
+
return projects[0].id;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// Not in a codev project
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
export async function porch(options) {
|
|
771
|
+
const { subcommand, args, dryRun, noClaude, pollInterval, description, worktree } = options;
|
|
772
|
+
switch (subcommand.toLowerCase()) {
|
|
773
|
+
case 'run': {
|
|
774
|
+
let projectId = args[0];
|
|
775
|
+
// Auto-detect project if not provided
|
|
776
|
+
if (!projectId) {
|
|
777
|
+
const detected = autoDetectProject();
|
|
778
|
+
if (!detected) {
|
|
779
|
+
throw new Error('Usage: porch run <project-id>\n' +
|
|
780
|
+
'Or run from a project worktree to auto-detect.');
|
|
781
|
+
}
|
|
782
|
+
projectId = detected;
|
|
783
|
+
console.log(chalk.blue(`[porch] Auto-detected project: ${projectId}`));
|
|
784
|
+
}
|
|
785
|
+
await run(projectId, { dryRun, noClaude, pollInterval });
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case 'init': {
|
|
789
|
+
if (args.length < 3) {
|
|
790
|
+
throw new Error('Usage: porch init <protocol> <project-id> <project-name>');
|
|
791
|
+
}
|
|
792
|
+
await init(args[0], args[1], args[2], { description, worktree });
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
case 'approve': {
|
|
796
|
+
if (args.length < 2) {
|
|
797
|
+
throw new Error('Usage: porch approve <project-id> <gate-id>');
|
|
798
|
+
}
|
|
799
|
+
await approve(args[0], args[1]);
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
case 'status': {
|
|
803
|
+
await status(args[0]);
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case 'pending': {
|
|
807
|
+
await pending();
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
case 'list':
|
|
811
|
+
case 'list-protocols': {
|
|
812
|
+
await list();
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case 'show':
|
|
816
|
+
case 'show-protocol': {
|
|
817
|
+
if (args.length < 1) {
|
|
818
|
+
throw new Error('Usage: porch show <protocol>');
|
|
819
|
+
}
|
|
820
|
+
await show(args[0]);
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
default:
|
|
824
|
+
throw new Error(`Unknown subcommand: ${subcommand}\n` +
|
|
825
|
+
'Valid subcommands: run, init, approve, status, pending, list, show');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
//# sourceMappingURL=index.js.map
|