@cluesmith/codev 1.6.2 → 2.0.0-rc.2
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 +41 -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,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Porch State Management
|
|
3
|
+
*
|
|
4
|
+
* Handles project state persistence with:
|
|
5
|
+
* - Pure YAML format (no markdown frontmatter)
|
|
6
|
+
* - Atomic writes (tmp file + fsync + rename)
|
|
7
|
+
* - File locking (flock advisory locking)
|
|
8
|
+
* - Crash recovery
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { openSync, closeSync, fsyncSync, writeFileSync, renameSync, unlinkSync, readFileSync } from 'node:fs';
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Constants
|
|
15
|
+
// ============================================================================
|
|
16
|
+
/** Directory for SPIDER project state (relative to project root) */
|
|
17
|
+
export const PROJECTS_DIR = 'codev/projects';
|
|
18
|
+
/** Directory for TICK/BUGFIX execution state (relative to project root) */
|
|
19
|
+
export const EXECUTIONS_DIR = 'codev/executions';
|
|
20
|
+
/** Lock timeout in milliseconds */
|
|
21
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
22
|
+
/** Lock retry interval in milliseconds */
|
|
23
|
+
const LOCK_RETRY_MS = 100;
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Path Utilities
|
|
26
|
+
// ============================================================================
|
|
27
|
+
/**
|
|
28
|
+
* Get the status file path for a SPIDER project
|
|
29
|
+
*/
|
|
30
|
+
export function getProjectStatusPath(projectRoot, projectId, name) {
|
|
31
|
+
const projectDir = name
|
|
32
|
+
? path.join(projectRoot, PROJECTS_DIR, `${projectId}-${name}`)
|
|
33
|
+
: path.join(projectRoot, PROJECTS_DIR, projectId);
|
|
34
|
+
return path.join(projectDir, 'status.yaml');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get the status file path for a TICK/BUGFIX execution
|
|
38
|
+
*/
|
|
39
|
+
export function getExecutionStatusPath(projectRoot, protocol, id, name) {
|
|
40
|
+
const dirName = name ? `${protocol}_${id}_${name}` : `${protocol}_${id}`;
|
|
41
|
+
return path.join(projectRoot, EXECUTIONS_DIR, dirName, 'status.yaml');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get the project directory for a SPIDER project
|
|
45
|
+
*/
|
|
46
|
+
export function getProjectDir(projectRoot, projectId, name) {
|
|
47
|
+
return name
|
|
48
|
+
? path.join(projectRoot, PROJECTS_DIR, `${projectId}-${name}`)
|
|
49
|
+
: path.join(projectRoot, PROJECTS_DIR, projectId);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get the worktree path for a protocol execution
|
|
53
|
+
*/
|
|
54
|
+
export function getWorktreePath(projectRoot, protocol, id, name) {
|
|
55
|
+
const dirName = name ? `${protocol}_${id}_${name}` : `${protocol}_${id}`;
|
|
56
|
+
return path.join(projectRoot, 'worktrees', dirName);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Acquire an advisory lock on a file
|
|
60
|
+
* Creates a .lock file to indicate lock ownership
|
|
61
|
+
*/
|
|
62
|
+
export async function acquireLock(filePath) {
|
|
63
|
+
const lockFile = `${filePath}.lock`;
|
|
64
|
+
const startTime = Date.now();
|
|
65
|
+
while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
|
|
66
|
+
try {
|
|
67
|
+
// Try to create lock file exclusively
|
|
68
|
+
const fd = openSync(lockFile, 'wx');
|
|
69
|
+
// Write our PID for debugging
|
|
70
|
+
writeFileSync(lockFile, `${process.pid}\n`);
|
|
71
|
+
return { fd, lockFile };
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err.code === 'EEXIST') {
|
|
75
|
+
// Lock file exists, check if stale
|
|
76
|
+
try {
|
|
77
|
+
const stat = fs.statSync(lockFile);
|
|
78
|
+
// If lock is older than 60 seconds, consider it stale
|
|
79
|
+
if (Date.now() - stat.mtimeMs > 60000) {
|
|
80
|
+
unlinkSync(lockFile);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Lock file disappeared, retry
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Wait and retry
|
|
89
|
+
await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Failed to acquire lock on ${filePath} after ${LOCK_TIMEOUT_MS}ms`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Release an advisory lock
|
|
100
|
+
*/
|
|
101
|
+
export function releaseLock(lock) {
|
|
102
|
+
try {
|
|
103
|
+
closeSync(lock.fd);
|
|
104
|
+
unlinkSync(lock.lockFile);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Ignore errors during cleanup
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// YAML Serialization
|
|
112
|
+
// ============================================================================
|
|
113
|
+
/**
|
|
114
|
+
* Simple YAML serializer for project state
|
|
115
|
+
* Handles our specific data structures without external dependencies
|
|
116
|
+
*/
|
|
117
|
+
export function serializeState(state) {
|
|
118
|
+
const lines = [];
|
|
119
|
+
// Basic fields
|
|
120
|
+
lines.push(`id: "${state.id}"`);
|
|
121
|
+
lines.push(`title: "${state.title}"`);
|
|
122
|
+
lines.push(`protocol: "${state.protocol}"`);
|
|
123
|
+
lines.push(`state: "${state.current_state}"`);
|
|
124
|
+
if (state.worktree) {
|
|
125
|
+
lines.push(`worktree: "${state.worktree}"`);
|
|
126
|
+
}
|
|
127
|
+
lines.push('');
|
|
128
|
+
// Gates
|
|
129
|
+
lines.push('gates:');
|
|
130
|
+
if (state.gates && Object.keys(state.gates).length > 0) {
|
|
131
|
+
for (const [gateId, gateStatus] of Object.entries(state.gates)) {
|
|
132
|
+
const status = gateStatus.status || 'pending';
|
|
133
|
+
const requestedAt = gateStatus.requested_at ? `, requested_at: "${gateStatus.requested_at}"` : '';
|
|
134
|
+
const approvedAt = gateStatus.approved_at ? `, approved_at: "${gateStatus.approved_at}"` : '';
|
|
135
|
+
lines.push(` ${gateId}: { status: ${status}${requestedAt}${approvedAt} }`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
lines.push(' # No gates defined');
|
|
140
|
+
}
|
|
141
|
+
lines.push('');
|
|
142
|
+
// Phases (for phased implementation)
|
|
143
|
+
lines.push('phases:');
|
|
144
|
+
if (state.phases && Object.keys(state.phases).length > 0) {
|
|
145
|
+
for (const [phaseId, phaseStatus] of Object.entries(state.phases)) {
|
|
146
|
+
if (typeof phaseStatus === 'object' && phaseStatus !== null) {
|
|
147
|
+
const ps = phaseStatus;
|
|
148
|
+
const status = ps.status || 'pending';
|
|
149
|
+
const title = ps.title ? `, title: "${ps.title}"` : '';
|
|
150
|
+
lines.push(` ${phaseId}: { status: ${status}${title} }`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
lines.push(' # No phases extracted yet');
|
|
156
|
+
}
|
|
157
|
+
lines.push('');
|
|
158
|
+
// Plan phases (extracted from plan.md)
|
|
159
|
+
if (state.plan_phases && state.plan_phases.length > 0) {
|
|
160
|
+
lines.push('plan_phases:');
|
|
161
|
+
for (const phase of state.plan_phases) {
|
|
162
|
+
lines.push(` - id: "${phase.id}"`);
|
|
163
|
+
lines.push(` title: "${phase.title}"`);
|
|
164
|
+
if (phase.description) {
|
|
165
|
+
lines.push(` description: "${phase.description.replace(/"/g, '\\"')}"`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
lines.push('');
|
|
169
|
+
}
|
|
170
|
+
// Consultation attempts (for tracking retries across iterations)
|
|
171
|
+
if (state.consultation_attempts && Object.keys(state.consultation_attempts).length > 0) {
|
|
172
|
+
lines.push('consultation_attempts:');
|
|
173
|
+
for (const [stateKey, count] of Object.entries(state.consultation_attempts)) {
|
|
174
|
+
lines.push(` "${stateKey}": ${count}`);
|
|
175
|
+
}
|
|
176
|
+
lines.push('');
|
|
177
|
+
}
|
|
178
|
+
// Metadata
|
|
179
|
+
lines.push(`iteration: ${state.iteration || 0}`);
|
|
180
|
+
lines.push(`started_at: "${state.started_at || new Date().toISOString()}"`);
|
|
181
|
+
lines.push(`last_updated: "${new Date().toISOString()}"`);
|
|
182
|
+
lines.push('');
|
|
183
|
+
// Log
|
|
184
|
+
lines.push('log:');
|
|
185
|
+
if (state.log && state.log.length > 0) {
|
|
186
|
+
for (const entry of state.log) {
|
|
187
|
+
if (typeof entry === 'string') {
|
|
188
|
+
lines.push(` - "${entry}"`);
|
|
189
|
+
}
|
|
190
|
+
else if (typeof entry === 'object' && entry !== null) {
|
|
191
|
+
const logEntry = entry;
|
|
192
|
+
const ts = logEntry.ts || new Date().toISOString();
|
|
193
|
+
const event = logEntry.event || 'unknown';
|
|
194
|
+
let entryLine = ` - ts: "${ts}"`;
|
|
195
|
+
lines.push(entryLine);
|
|
196
|
+
lines.push(` event: "${event}"`);
|
|
197
|
+
if (logEntry.from)
|
|
198
|
+
lines.push(` from: "${logEntry.from}"`);
|
|
199
|
+
if (logEntry.to)
|
|
200
|
+
lines.push(` to: "${logEntry.to}"`);
|
|
201
|
+
if (logEntry.signal)
|
|
202
|
+
lines.push(` signal: "${logEntry.signal}"`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return lines.join('\n') + '\n';
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Parse YAML status file into ProjectState
|
|
210
|
+
*/
|
|
211
|
+
export function parseState(content) {
|
|
212
|
+
const state = {
|
|
213
|
+
gates: {},
|
|
214
|
+
phases: {},
|
|
215
|
+
log: [],
|
|
216
|
+
consultation_attempts: {},
|
|
217
|
+
};
|
|
218
|
+
const lines = content.split('\n');
|
|
219
|
+
let currentSection = '';
|
|
220
|
+
let currentArrayItem = null;
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
// Skip comments and empty lines
|
|
223
|
+
if (line.trim().startsWith('#') || line.trim() === '') {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
// Detect section headers
|
|
227
|
+
if (line.match(/^gates:\s*$/)) {
|
|
228
|
+
currentSection = 'gates';
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (line.match(/^phases:\s*$/)) {
|
|
232
|
+
currentSection = 'phases';
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (line.match(/^plan_phases:\s*$/)) {
|
|
236
|
+
currentSection = 'plan_phases';
|
|
237
|
+
state.plan_phases = [];
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (line.match(/^log:\s*$/)) {
|
|
241
|
+
currentSection = 'log';
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (line.match(/^consultation_attempts:\s*$/)) {
|
|
245
|
+
currentSection = 'consultation_attempts';
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Parse based on section
|
|
249
|
+
if (currentSection === 'gates') {
|
|
250
|
+
// Parse: gate_id: { status: pending, requested_at: "..." }
|
|
251
|
+
const match = line.match(/^\s+(\w+):\s*\{\s*status:\s*(\w+)(?:,\s*requested_at:\s*"([^"]*)")?(?:,\s*approved_at:\s*"([^"]*)")?\s*\}/);
|
|
252
|
+
if (match) {
|
|
253
|
+
const [, gateId, status, requestedAt, approvedAt] = match;
|
|
254
|
+
state.gates[gateId] = {
|
|
255
|
+
status: status,
|
|
256
|
+
...(requestedAt && { requested_at: requestedAt }),
|
|
257
|
+
...(approvedAt && { approved_at: approvedAt }),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (currentSection === 'phases') {
|
|
263
|
+
// Parse: phase_id: { status: pending, title: "..." }
|
|
264
|
+
const match = line.match(/^\s+(\w+):\s*\{\s*status:\s*(\w+)(?:,\s*title:\s*"([^"]*)")?\s*\}/);
|
|
265
|
+
if (match) {
|
|
266
|
+
const [, phaseId, status, title] = match;
|
|
267
|
+
state.phases[phaseId] = {
|
|
268
|
+
status: status,
|
|
269
|
+
...(title && { title }),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (currentSection === 'plan_phases') {
|
|
275
|
+
// Parse array items
|
|
276
|
+
if (line.match(/^\s+-\s+id:/)) {
|
|
277
|
+
if (currentArrayItem) {
|
|
278
|
+
state.plan_phases.push(currentArrayItem);
|
|
279
|
+
}
|
|
280
|
+
currentArrayItem = {};
|
|
281
|
+
const idMatch = line.match(/id:\s*"([^"]*)"/);
|
|
282
|
+
if (idMatch)
|
|
283
|
+
currentArrayItem.id = idMatch[1];
|
|
284
|
+
}
|
|
285
|
+
else if (line.match(/^\s+title:/)) {
|
|
286
|
+
const titleMatch = line.match(/title:\s*"([^"]*)"/);
|
|
287
|
+
if (titleMatch && currentArrayItem)
|
|
288
|
+
currentArrayItem.title = titleMatch[1];
|
|
289
|
+
}
|
|
290
|
+
else if (line.match(/^\s+description:/)) {
|
|
291
|
+
const descMatch = line.match(/description:\s*"([^"]*)"/);
|
|
292
|
+
if (descMatch && currentArrayItem)
|
|
293
|
+
currentArrayItem.description = descMatch[1];
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (currentSection === 'consultation_attempts') {
|
|
298
|
+
// Parse: "state:key": count
|
|
299
|
+
const match = line.match(/^\s+"([^"]+)":\s*(\d+)/);
|
|
300
|
+
if (match) {
|
|
301
|
+
const [, stateKey, count] = match;
|
|
302
|
+
state.consultation_attempts[stateKey] = parseInt(count, 10);
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (currentSection === 'log') {
|
|
307
|
+
// Parse log entries - simplified
|
|
308
|
+
if (line.match(/^\s+-\s+ts:/)) {
|
|
309
|
+
if (currentArrayItem) {
|
|
310
|
+
state.log.push(currentArrayItem);
|
|
311
|
+
}
|
|
312
|
+
currentArrayItem = {};
|
|
313
|
+
const tsMatch = line.match(/ts:\s*"([^"]*)"/);
|
|
314
|
+
if (tsMatch)
|
|
315
|
+
currentArrayItem.ts = tsMatch[1];
|
|
316
|
+
}
|
|
317
|
+
else if (line.match(/^\s+event:/)) {
|
|
318
|
+
const eventMatch = line.match(/event:\s*"([^"]*)"/);
|
|
319
|
+
if (eventMatch && currentArrayItem)
|
|
320
|
+
currentArrayItem.event = eventMatch[1];
|
|
321
|
+
}
|
|
322
|
+
else if (line.match(/^\s+from:/)) {
|
|
323
|
+
const fromMatch = line.match(/from:\s*"([^"]*)"/);
|
|
324
|
+
if (fromMatch && currentArrayItem)
|
|
325
|
+
currentArrayItem.from = fromMatch[1];
|
|
326
|
+
}
|
|
327
|
+
else if (line.match(/^\s+to:/)) {
|
|
328
|
+
const toMatch = line.match(/to:\s*"([^"]*)"/);
|
|
329
|
+
if (toMatch && currentArrayItem)
|
|
330
|
+
currentArrayItem.to = toMatch[1];
|
|
331
|
+
}
|
|
332
|
+
else if (line.match(/^\s+signal:/)) {
|
|
333
|
+
const signalMatch = line.match(/signal:\s*"([^"]*)"/);
|
|
334
|
+
if (signalMatch && currentArrayItem)
|
|
335
|
+
currentArrayItem.signal = signalMatch[1];
|
|
336
|
+
}
|
|
337
|
+
else if (line.match(/^\s+-\s*"[^"]*"/)) {
|
|
338
|
+
// Simple string log entry
|
|
339
|
+
const strMatch = line.match(/^\s+-\s*"([^"]*)"/);
|
|
340
|
+
if (strMatch)
|
|
341
|
+
state.log.push(strMatch[1]);
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// Top-level fields
|
|
346
|
+
const kvMatch = line.match(/^(\w+):\s*"?([^"\n]*)"?$/);
|
|
347
|
+
if (kvMatch) {
|
|
348
|
+
const [, key, value] = kvMatch;
|
|
349
|
+
switch (key) {
|
|
350
|
+
case 'id':
|
|
351
|
+
state.id = value;
|
|
352
|
+
break;
|
|
353
|
+
case 'title':
|
|
354
|
+
state.title = value;
|
|
355
|
+
break;
|
|
356
|
+
case 'protocol':
|
|
357
|
+
state.protocol = value;
|
|
358
|
+
break;
|
|
359
|
+
case 'state':
|
|
360
|
+
state.current_state = value;
|
|
361
|
+
break;
|
|
362
|
+
case 'worktree':
|
|
363
|
+
state.worktree = value;
|
|
364
|
+
break;
|
|
365
|
+
case 'iteration':
|
|
366
|
+
state.iteration = parseInt(value, 10);
|
|
367
|
+
break;
|
|
368
|
+
case 'started_at':
|
|
369
|
+
state.started_at = value;
|
|
370
|
+
break;
|
|
371
|
+
case 'last_updated':
|
|
372
|
+
state.last_updated = value;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Push final array item if exists
|
|
378
|
+
if (currentArrayItem) {
|
|
379
|
+
if (currentSection === 'plan_phases') {
|
|
380
|
+
state.plan_phases.push(currentArrayItem);
|
|
381
|
+
}
|
|
382
|
+
else if (currentSection === 'log') {
|
|
383
|
+
state.log.push(currentArrayItem);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return state;
|
|
387
|
+
}
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// State Operations
|
|
390
|
+
// ============================================================================
|
|
391
|
+
/**
|
|
392
|
+
* Read project state from status file
|
|
393
|
+
*/
|
|
394
|
+
export function readState(statusFilePath) {
|
|
395
|
+
// Check for crash recovery - .tmp file exists
|
|
396
|
+
const tmpPath = `${statusFilePath}.tmp`;
|
|
397
|
+
if (fs.existsSync(tmpPath)) {
|
|
398
|
+
try {
|
|
399
|
+
const tmpContent = readFileSync(tmpPath, 'utf-8');
|
|
400
|
+
const tmpState = parseState(tmpContent);
|
|
401
|
+
// tmp file is valid, use it and clean up
|
|
402
|
+
renameSync(tmpPath, statusFilePath);
|
|
403
|
+
console.log('[porch] Recovered state from interrupted write');
|
|
404
|
+
return tmpState;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
// tmp file is corrupt, delete it
|
|
408
|
+
unlinkSync(tmpPath);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!fs.existsSync(statusFilePath)) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const content = readFileSync(statusFilePath, 'utf-8');
|
|
415
|
+
return parseState(content);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Write project state atomically
|
|
419
|
+
* Uses tmp file + fsync + rename for crash safety
|
|
420
|
+
*/
|
|
421
|
+
export async function writeState(statusFilePath, state) {
|
|
422
|
+
const lock = await acquireLock(statusFilePath);
|
|
423
|
+
try {
|
|
424
|
+
const content = serializeState(state);
|
|
425
|
+
const tmpPath = `${statusFilePath}.tmp`;
|
|
426
|
+
const dir = path.dirname(statusFilePath);
|
|
427
|
+
// Ensure directory exists
|
|
428
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
429
|
+
// Write to temp file
|
|
430
|
+
const fd = openSync(tmpPath, 'w');
|
|
431
|
+
writeFileSync(fd, content);
|
|
432
|
+
fsyncSync(fd);
|
|
433
|
+
closeSync(fd);
|
|
434
|
+
// Atomic rename
|
|
435
|
+
renameSync(tmpPath, statusFilePath);
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
releaseLock(lock);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Create initial project state
|
|
443
|
+
*/
|
|
444
|
+
export function createInitialState(protocol, projectId, title, worktreePath) {
|
|
445
|
+
const now = new Date().toISOString();
|
|
446
|
+
// Extract gates from protocol
|
|
447
|
+
const gates = {};
|
|
448
|
+
for (const phase of protocol.phases) {
|
|
449
|
+
if (phase.gate) {
|
|
450
|
+
const gateId = `${phase.id}_approval`;
|
|
451
|
+
gates[gateId] = { status: 'pending' };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
id: projectId,
|
|
456
|
+
title,
|
|
457
|
+
protocol: protocol.name,
|
|
458
|
+
current_state: protocol.initial || `${protocol.phases[0]?.id}:draft`,
|
|
459
|
+
worktree: worktreePath,
|
|
460
|
+
gates,
|
|
461
|
+
phases: {},
|
|
462
|
+
plan_phases: [],
|
|
463
|
+
iteration: 0,
|
|
464
|
+
started_at: now,
|
|
465
|
+
last_updated: now,
|
|
466
|
+
log: [{
|
|
467
|
+
ts: now,
|
|
468
|
+
event: 'state_change',
|
|
469
|
+
from: null,
|
|
470
|
+
to: protocol.initial || `${protocol.phases[0]?.id}:draft`,
|
|
471
|
+
}],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Update state with a new current state
|
|
476
|
+
*/
|
|
477
|
+
export function updateState(state, newState, options = {}) {
|
|
478
|
+
const now = new Date().toISOString();
|
|
479
|
+
const logEntry = {
|
|
480
|
+
ts: now,
|
|
481
|
+
event: 'state_change',
|
|
482
|
+
from: state.current_state,
|
|
483
|
+
to: newState,
|
|
484
|
+
};
|
|
485
|
+
if (options.signal) {
|
|
486
|
+
logEntry.signal = options.signal;
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
...state,
|
|
490
|
+
current_state: newState,
|
|
491
|
+
iteration: state.iteration + 1,
|
|
492
|
+
last_updated: now,
|
|
493
|
+
log: [...state.log, logEntry],
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Approve a gate in state
|
|
498
|
+
*/
|
|
499
|
+
export function approveGate(state, gateId) {
|
|
500
|
+
const now = new Date().toISOString();
|
|
501
|
+
return {
|
|
502
|
+
...state,
|
|
503
|
+
gates: {
|
|
504
|
+
...state.gates,
|
|
505
|
+
[gateId]: {
|
|
506
|
+
...state.gates[gateId],
|
|
507
|
+
status: 'passed',
|
|
508
|
+
approved_at: now,
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
last_updated: now,
|
|
512
|
+
log: [...state.log, {
|
|
513
|
+
ts: now,
|
|
514
|
+
event: 'gate_approved',
|
|
515
|
+
gate: gateId,
|
|
516
|
+
}],
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Request a gate approval (mark as pending with timestamp)
|
|
521
|
+
*/
|
|
522
|
+
export function requestGateApproval(state, gateId) {
|
|
523
|
+
const now = new Date().toISOString();
|
|
524
|
+
return {
|
|
525
|
+
...state,
|
|
526
|
+
gates: {
|
|
527
|
+
...state.gates,
|
|
528
|
+
[gateId]: {
|
|
529
|
+
status: 'pending',
|
|
530
|
+
requested_at: now,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
last_updated: now,
|
|
534
|
+
log: [...state.log, {
|
|
535
|
+
ts: now,
|
|
536
|
+
event: 'gate_requested',
|
|
537
|
+
gate: gateId,
|
|
538
|
+
}],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Update phase status
|
|
543
|
+
*/
|
|
544
|
+
export function updatePhaseStatus(state, phaseId, status) {
|
|
545
|
+
const now = new Date().toISOString();
|
|
546
|
+
return {
|
|
547
|
+
...state,
|
|
548
|
+
phases: {
|
|
549
|
+
...state.phases,
|
|
550
|
+
[phaseId]: {
|
|
551
|
+
...state.phases[phaseId],
|
|
552
|
+
status,
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
last_updated: now,
|
|
556
|
+
log: [...state.log, {
|
|
557
|
+
ts: now,
|
|
558
|
+
event: 'phase_status_change',
|
|
559
|
+
phase: phaseId,
|
|
560
|
+
status,
|
|
561
|
+
}],
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Set plan phases extracted from plan.md
|
|
566
|
+
*/
|
|
567
|
+
export function setPlanPhases(state, phases) {
|
|
568
|
+
const now = new Date().toISOString();
|
|
569
|
+
// Initialize phase status for each extracted phase
|
|
570
|
+
const phaseStatus = {};
|
|
571
|
+
for (const phase of phases) {
|
|
572
|
+
phaseStatus[phase.id] = { status: 'pending', title: phase.title };
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
...state,
|
|
576
|
+
plan_phases: phases,
|
|
577
|
+
phases: phaseStatus,
|
|
578
|
+
last_updated: now,
|
|
579
|
+
log: [...state.log, {
|
|
580
|
+
ts: now,
|
|
581
|
+
event: 'plan_phases_extracted',
|
|
582
|
+
count: phases.length,
|
|
583
|
+
}],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// ============================================================================
|
|
587
|
+
// Discovery
|
|
588
|
+
// ============================================================================
|
|
589
|
+
/**
|
|
590
|
+
* Find all SPIDER projects
|
|
591
|
+
*/
|
|
592
|
+
export function findProjects(projectRoot) {
|
|
593
|
+
const projectsDir = path.join(projectRoot, PROJECTS_DIR);
|
|
594
|
+
if (!fs.existsSync(projectsDir)) {
|
|
595
|
+
return [];
|
|
596
|
+
}
|
|
597
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
598
|
+
const projects = [];
|
|
599
|
+
for (const entry of entries) {
|
|
600
|
+
if (entry.isDirectory()) {
|
|
601
|
+
const statusPath = path.join(projectsDir, entry.name, 'status.yaml');
|
|
602
|
+
if (fs.existsSync(statusPath)) {
|
|
603
|
+
// Extract project ID from directory name (format: NNNN-name or just NNNN)
|
|
604
|
+
const idMatch = entry.name.match(/^(\d+)/);
|
|
605
|
+
if (idMatch) {
|
|
606
|
+
projects.push({
|
|
607
|
+
id: idMatch[1],
|
|
608
|
+
path: statusPath,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return projects;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Find all executions (TICK, BUGFIX, etc.)
|
|
618
|
+
*/
|
|
619
|
+
export function findExecutions(projectRoot) {
|
|
620
|
+
const executionsDir = path.join(projectRoot, EXECUTIONS_DIR);
|
|
621
|
+
if (!fs.existsSync(executionsDir)) {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
const entries = fs.readdirSync(executionsDir, { withFileTypes: true });
|
|
625
|
+
const executions = [];
|
|
626
|
+
for (const entry of entries) {
|
|
627
|
+
if (entry.isDirectory()) {
|
|
628
|
+
const statusPath = path.join(executionsDir, entry.name, 'status.yaml');
|
|
629
|
+
if (fs.existsSync(statusPath)) {
|
|
630
|
+
// Parse directory name (format: protocol_id_name)
|
|
631
|
+
const match = entry.name.match(/^(\w+)_(\w+)/);
|
|
632
|
+
if (match) {
|
|
633
|
+
executions.push({
|
|
634
|
+
protocol: match[1],
|
|
635
|
+
id: match[2],
|
|
636
|
+
path: statusPath,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return executions;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Find status file for a project by ID
|
|
646
|
+
*/
|
|
647
|
+
export function findStatusFile(projectRoot, projectId) {
|
|
648
|
+
// Check projects directory first
|
|
649
|
+
const projectsDir = path.join(projectRoot, PROJECTS_DIR);
|
|
650
|
+
if (fs.existsSync(projectsDir)) {
|
|
651
|
+
const entries = fs.readdirSync(projectsDir);
|
|
652
|
+
for (const entry of entries) {
|
|
653
|
+
if (entry.startsWith(projectId)) {
|
|
654
|
+
const statusPath = path.join(projectsDir, entry, 'status.yaml');
|
|
655
|
+
if (fs.existsSync(statusPath)) {
|
|
656
|
+
return statusPath;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// Check executions directory
|
|
662
|
+
const executionsDir = path.join(projectRoot, EXECUTIONS_DIR);
|
|
663
|
+
if (fs.existsSync(executionsDir)) {
|
|
664
|
+
const entries = fs.readdirSync(executionsDir);
|
|
665
|
+
for (const entry of entries) {
|
|
666
|
+
if (entry.includes(`_${projectId}`)) {
|
|
667
|
+
const statusPath = path.join(executionsDir, entry, 'status.yaml');
|
|
668
|
+
if (fs.existsSync(statusPath)) {
|
|
669
|
+
return statusPath;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
// ============================================================================
|
|
677
|
+
// Consultation Attempt Tracking
|
|
678
|
+
// ============================================================================
|
|
679
|
+
/**
|
|
680
|
+
* Get the number of consultation attempts for a given state
|
|
681
|
+
*/
|
|
682
|
+
export function getConsultationAttempts(state, stateKey) {
|
|
683
|
+
return state.consultation_attempts?.[stateKey] ?? 0;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Increment consultation attempts for a given state
|
|
687
|
+
*/
|
|
688
|
+
export function incrementConsultationAttempts(state, stateKey) {
|
|
689
|
+
const now = new Date().toISOString();
|
|
690
|
+
const currentAttempts = getConsultationAttempts(state, stateKey);
|
|
691
|
+
return {
|
|
692
|
+
...state,
|
|
693
|
+
consultation_attempts: {
|
|
694
|
+
...state.consultation_attempts,
|
|
695
|
+
[stateKey]: currentAttempts + 1,
|
|
696
|
+
},
|
|
697
|
+
last_updated: now,
|
|
698
|
+
log: [...state.log, {
|
|
699
|
+
ts: now,
|
|
700
|
+
event: 'consultation_attempt',
|
|
701
|
+
phase: stateKey,
|
|
702
|
+
count: currentAttempts + 1,
|
|
703
|
+
}],
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Reset consultation attempts for a given state (e.g., after gate approval)
|
|
708
|
+
*/
|
|
709
|
+
export function resetConsultationAttempts(state, stateKey) {
|
|
710
|
+
const newAttempts = { ...state.consultation_attempts };
|
|
711
|
+
delete newAttempts[stateKey];
|
|
712
|
+
return {
|
|
713
|
+
...state,
|
|
714
|
+
consultation_attempts: newAttempts,
|
|
715
|
+
last_updated: new Date().toISOString(),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
// ============================================================================
|
|
719
|
+
// Discovery
|
|
720
|
+
// ============================================================================
|
|
721
|
+
/**
|
|
722
|
+
* Find all status files with pending gates
|
|
723
|
+
*/
|
|
724
|
+
export function findPendingGates(projectRoot) {
|
|
725
|
+
const pending = [];
|
|
726
|
+
// Check projects
|
|
727
|
+
for (const { id, path: statusPath } of findProjects(projectRoot)) {
|
|
728
|
+
const state = readState(statusPath);
|
|
729
|
+
if (state && state.gates) {
|
|
730
|
+
for (const [gateId, gateStatus] of Object.entries(state.gates)) {
|
|
731
|
+
if (gateStatus.status === 'pending' && gateStatus.requested_at) {
|
|
732
|
+
pending.push({
|
|
733
|
+
projectId: id,
|
|
734
|
+
gateId,
|
|
735
|
+
requestedAt: gateStatus.requested_at,
|
|
736
|
+
statusPath,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Check executions
|
|
743
|
+
for (const { id, path: statusPath } of findExecutions(projectRoot)) {
|
|
744
|
+
const state = readState(statusPath);
|
|
745
|
+
if (state && state.gates) {
|
|
746
|
+
for (const [gateId, gateStatus] of Object.entries(state.gates)) {
|
|
747
|
+
if (gateStatus.status === 'pending' && gateStatus.requested_at) {
|
|
748
|
+
pending.push({
|
|
749
|
+
projectId: id,
|
|
750
|
+
gateId,
|
|
751
|
+
requestedAt: gateStatus.requested_at,
|
|
752
|
+
statusPath,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return pending;
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=state.js.map
|