@ghostwater/soulforge 0.6.0 → 0.8.0

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 (46) hide show
  1. package/dist/cli/cli.js +323 -117
  2. package/dist/cli/cli.js.map +1 -1
  3. package/dist/daemon/daemon.js +112 -6
  4. package/dist/daemon/daemon.js.map +1 -1
  5. package/dist/daemon/runner.js +634 -64
  6. package/dist/daemon/runner.js.map +1 -1
  7. package/dist/db/database.d.ts +20 -10
  8. package/dist/db/database.js +182 -44
  9. package/dist/db/database.js.map +1 -1
  10. package/dist/executors/claude-code.d.ts +2 -0
  11. package/dist/executors/claude-code.js +38 -2
  12. package/dist/executors/claude-code.js.map +1 -1
  13. package/dist/executors/codex-cli.d.ts +2 -0
  14. package/dist/executors/codex-cli.js +37 -2
  15. package/dist/executors/codex-cli.js.map +1 -1
  16. package/dist/executors/codex.d.ts +1 -0
  17. package/dist/executors/codex.js +53 -0
  18. package/dist/executors/codex.js.map +1 -1
  19. package/dist/executors/openclaw.d.ts +1 -0
  20. package/dist/executors/openclaw.js +11 -0
  21. package/dist/executors/openclaw.js.map +1 -1
  22. package/dist/executors/self.d.ts +1 -0
  23. package/dist/executors/self.js +11 -0
  24. package/dist/executors/self.js.map +1 -1
  25. package/dist/executors/types.d.ts +4 -0
  26. package/dist/lib/worktree.d.ts +1 -1
  27. package/dist/lib/worktree.js +2 -1
  28. package/dist/lib/worktree.js.map +1 -1
  29. package/dist/workflow/discovery.d.ts +24 -0
  30. package/dist/workflow/discovery.js +120 -0
  31. package/dist/workflow/discovery.js.map +1 -0
  32. package/dist/workflow/gate-routing.d.ts +5 -0
  33. package/dist/workflow/gate-routing.js +44 -0
  34. package/dist/workflow/gate-routing.js.map +1 -0
  35. package/dist/workflow/parser.js +117 -5
  36. package/dist/workflow/parser.js.map +1 -1
  37. package/dist/workflow/schema-validator.d.ts +6 -0
  38. package/dist/workflow/schema-validator.js +120 -0
  39. package/dist/workflow/schema-validator.js.map +1 -0
  40. package/dist/workflow/template.d.ts +2 -1
  41. package/dist/workflow/template.js +13 -21
  42. package/dist/workflow/template.js.map +1 -1
  43. package/dist/workflow/types.d.ts +22 -2
  44. package/package.json +1 -1
  45. package/workflows/bugfix/workflow.yml +248 -40
  46. package/workflows/feature-dev/workflow.yml +252 -48
package/dist/cli/cli.js CHANGED
@@ -2,16 +2,16 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
- import { execSync } from "node:child_process";
6
- import crypto from "node:crypto";
7
5
  import fs from "node:fs";
8
6
  import path from "node:path";
9
- import { createRun, getRun, listRuns, getStepsForRun, getStoriesForRun, getEvents, updateStepStatus, updateRunStatus, advancePipeline, insertEvent, resetToStep, getDb, } from "../db/database.js";
7
+ import { createRun, getRun, listRuns, getStepsForRun, getLoopItemsForRun, getEvents, updateStepStatus, updateRunStatus, updateRunContext, advancePipeline, insertEvent, resetToStep, getDb, getDataDir, } from "../db/database.js";
10
8
  import { loadWorkflowSpec } from "../workflow/parser.js";
9
+ import { validateOutput } from "../workflow/schema-validator.js";
10
+ import { getContextValue, parseGateLoopCounts, resolveGateRoute } from "../workflow/gate-routing.js";
11
+ import { findBuiltinWorkflowByName, findNamedWorkflow, getCustomWorkflowPath, getCustomWorkflowRoot, listDiscoveredWorkflows, resolveWorkflowInput, } from "../workflow/discovery.js";
11
12
  import { listExecutors } from "../executors/registry.js";
12
13
  import { isDaemonRunning, startDaemonBackground, stopDaemon } from "../daemon/daemon.js";
13
14
  import { readRecentLogs } from "../lib/logger.js";
14
- import { createWorktree, validateGitRepo } from "../lib/worktree.js";
15
15
  const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
17
17
  function getVersion() {
@@ -35,20 +35,28 @@ Usage:
35
35
  soulforge run <workflow> "<task>" [options]
36
36
  Start a workflow run
37
37
  --var key=value Set workflow variable
38
- --workdir <path> Work in an existing directory (no git ops)
39
- --no-worktree Work directly in the repo without creating a worktree
40
- --branch <name> Branch name for worktree (default: soulforge/<id>)
41
- --callback-url <url> Callback URL for step notifications (required)
38
+ --workdir <path> Work in an existing directory (required)
39
+ --keep-worktree Keep worktree metadata/files after run completion
40
+ --callback-url <url> Callback URL for step notifications
41
+ --callback-exec <command> Shell command callback for step notifications
42
42
  --callback-headers <json> Headers for callback requests
43
43
  --callback-body <json> Body template for callback requests
44
- --no-callback Run without callbacks (explicit opt-out)
44
+ --no-callback Run without callbacks (disables URL + exec callbacks)
45
45
  --executor <name> Override executor for all code steps (e.g., codex-cli)
46
+ --model <name> Override model for all code steps (e.g., gpt-4o)
47
+ soulforge workflow list List built-in and custom workflows
48
+ soulforge workflow show <name>
49
+ Show workflow YAML by name
50
+ soulforge workflow create <name> [--from <template>] [--force]
51
+ Create custom workflow in ~/.soulforge/workflows
46
52
  soulforge status [<query>] Check run status (ID prefix or task substring)
47
53
  soulforge runs List all runs
48
54
  soulforge approve <run-id> [--message "..."]
49
55
  Approve a checkpoint
50
56
  soulforge reject <run-id> --reason "..."
51
57
  Reject a checkpoint
58
+ soulforge complete --run-id <id> --step-id <id> --data '<json>'
59
+ Complete a step and persist output data
52
60
  soulforge cancel <run-id> Cancel a running workflow
53
61
  soulforge resume <run-id> Resume a failed run
54
62
 
@@ -61,6 +69,46 @@ Usage:
61
69
  Environment:
62
70
  SOULFORGE_DATA_DIR Data directory (default: ~/.soulforge)`);
63
71
  }
72
+ function isPlainObject(value) {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+ function formatDaemonStatusLine() {
76
+ const status = isDaemonRunning();
77
+ if (!status.running || !status.pid) {
78
+ return "Daemon: not running";
79
+ }
80
+ const heartbeatPath = path.join(getDataDir(), "heartbeat");
81
+ try {
82
+ const heartbeatRaw = readFileSync(heartbeatPath, "utf-8").trim();
83
+ const heartbeat = Number.parseInt(heartbeatRaw, 10);
84
+ if (!Number.isFinite(heartbeat)) {
85
+ throw new Error("Heartbeat is not numeric");
86
+ }
87
+ const ageMs = Math.max(0, Date.now() - heartbeat);
88
+ if (ageMs < 30_000) {
89
+ const ageSeconds = Math.floor(ageMs / 1000);
90
+ return `Daemon: running (PID ${status.pid}, last tick ${ageSeconds}s ago)`;
91
+ }
92
+ const ageMinutes = Math.max(1, Math.floor(ageMs / 60_000));
93
+ return `Daemon: ⚠️ running (PID ${status.pid}, last tick ${ageMinutes}m ago — may be hung)`;
94
+ }
95
+ catch {
96
+ return `Daemon: ⚠️ running (PID ${status.pid}, last tick unknown — may be hung)`;
97
+ }
98
+ }
99
+ function isValidWorkflowName(name) {
100
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
101
+ }
102
+ function buildDefaultWorkflowTemplate(name) {
103
+ return `id: ${name}
104
+ name: ${name}
105
+ steps:
106
+ - id: implement
107
+ executor: codex
108
+ input: |
109
+ Implement the requested task.
110
+ `;
111
+ }
64
112
  async function main() {
65
113
  const args = process.argv.slice(2);
66
114
  const [group, action, ...rest] = args;
@@ -93,16 +141,83 @@ async function main() {
93
141
  return;
94
142
  }
95
143
  if (action === "status") {
96
- const status = isDaemonRunning();
97
- if (status.running) {
98
- console.log(`Daemon running (PID ${status.pid})`);
144
+ console.log(formatDaemonStatusLine());
145
+ return;
146
+ }
147
+ printUsage();
148
+ process.exit(1);
149
+ }
150
+ // ── Workflow commands ───────────────────────────────────────────
151
+ if (group === "workflow") {
152
+ if (action === "list") {
153
+ const workflows = await listDiscoveredWorkflows();
154
+ if (workflows.length === 0) {
155
+ console.log("No workflows found.");
156
+ return;
157
+ }
158
+ for (const workflow of workflows) {
159
+ console.log(`${workflow.name}\t${workflow.source}`);
160
+ }
161
+ return;
162
+ }
163
+ if (action === "show") {
164
+ const name = rest[0];
165
+ if (!name) {
166
+ console.error("Missing workflow name. Usage: soulforge workflow show <name>");
167
+ process.exit(1);
168
+ }
169
+ const workflow = await findNamedWorkflow(name);
170
+ if (!workflow) {
171
+ console.error(`Workflow not found by name: ${name}`);
172
+ process.exit(1);
173
+ }
174
+ console.log(readFileSync(workflow.path, "utf-8"));
175
+ return;
176
+ }
177
+ if (action === "create") {
178
+ const name = rest[0];
179
+ if (!name) {
180
+ console.error("Missing workflow name. Usage: soulforge workflow create <name> [--from <template>] [--force]");
181
+ process.exit(1);
182
+ }
183
+ if (!isValidWorkflowName(name)) {
184
+ console.error(`Invalid workflow name "${name}". Use letters, numbers, dot, underscore, and hyphen only.`);
185
+ process.exit(1);
186
+ }
187
+ let fromTemplate;
188
+ let force = false;
189
+ for (let i = 1; i < rest.length; i += 1) {
190
+ if (rest[i] === "--from" && rest[i + 1]) {
191
+ fromTemplate = rest[i + 1];
192
+ i += 1;
193
+ }
194
+ else if (rest[i] === "--force") {
195
+ force = true;
196
+ }
197
+ }
198
+ const destinationPath = getCustomWorkflowPath(name);
199
+ if (fs.existsSync(destinationPath) && !force) {
200
+ console.error(`Workflow already exists: ${destinationPath}. Use --force to overwrite.`);
201
+ process.exit(1);
202
+ }
203
+ let templateContent;
204
+ if (fromTemplate) {
205
+ const sourceWorkflow = await findBuiltinWorkflowByName(fromTemplate);
206
+ if (!sourceWorkflow) {
207
+ console.error(`Built-in workflow not found: ${fromTemplate}`);
208
+ process.exit(1);
209
+ }
210
+ templateContent = readFileSync(sourceWorkflow.path, "utf-8");
99
211
  }
100
212
  else {
101
- console.log("Daemon is not running.");
213
+ templateContent = buildDefaultWorkflowTemplate(name);
102
214
  }
215
+ fs.mkdirSync(getCustomWorkflowRoot(), { recursive: true });
216
+ fs.writeFileSync(destinationPath, templateContent);
217
+ console.log(`Created workflow: ${destinationPath}`);
103
218
  return;
104
219
  }
105
- printUsage();
220
+ console.error("Unknown workflow command. Use: list, show, create");
106
221
  process.exit(1);
107
222
  }
108
223
  // ── Run command ─────────────────────────────────────────────────
@@ -119,13 +234,15 @@ async function main() {
119
234
  // Parse flags (order-independent)
120
235
  const vars = {};
121
236
  let workdir;
122
- let noWorktree = false;
123
- let branchName;
124
237
  let callbackUrl;
238
+ let callbackExec;
125
239
  let callbackHeaders;
126
240
  let callbackBodyTemplate;
127
241
  let noCallback = false;
242
+ let keepWorktree = false;
128
243
  let executorOverride;
244
+ let modelOverride;
245
+ let modelFlagSeen = false;
129
246
  for (let i = 1; i < rest.length; i++) {
130
247
  if (rest[i] === "--var" && rest[i + 1]) {
131
248
  const [key, ...valParts] = rest[i + 1].split("=");
@@ -136,17 +253,14 @@ async function main() {
136
253
  workdir = rest[i + 1];
137
254
  i++;
138
255
  }
139
- else if (rest[i] === "--no-worktree") {
140
- noWorktree = true;
141
- }
142
- else if (rest[i] === "--branch" && rest[i + 1]) {
143
- branchName = rest[i + 1];
144
- i++;
145
- }
146
256
  else if (rest[i] === "--callback-url" && rest[i + 1]) {
147
257
  callbackUrl = rest[i + 1];
148
258
  i++;
149
259
  }
260
+ else if (rest[i] === "--callback-exec" && rest[i + 1]) {
261
+ callbackExec = rest[i + 1];
262
+ i++;
263
+ }
150
264
  else if (rest[i] === "--callback-headers" && rest[i + 1]) {
151
265
  try {
152
266
  callbackHeaders = JSON.parse(rest[i + 1]);
@@ -170,36 +284,51 @@ async function main() {
170
284
  else if (rest[i] === "--no-callback") {
171
285
  noCallback = true;
172
286
  }
287
+ else if (rest[i] === "--keep-worktree") {
288
+ keepWorktree = true;
289
+ }
173
290
  else if (rest[i] === "--executor" && rest[i + 1]) {
174
291
  executorOverride = rest[i + 1];
175
292
  i++;
176
293
  }
294
+ else if (rest[i] === "--model") {
295
+ modelFlagSeen = true;
296
+ modelOverride = rest[i + 1];
297
+ if (rest[i + 1] !== undefined) {
298
+ i++;
299
+ }
300
+ }
301
+ }
302
+ if (!workdir) {
303
+ if (vars.repo) {
304
+ console.error("Error: --workdir is required. Specify the project directory for this run. (Hint: --var repo is no longer supported, use --workdir instead)");
305
+ }
306
+ else {
307
+ console.error("Error: --workdir is required. Specify the project directory for this run.");
308
+ }
309
+ process.exit(1);
177
310
  }
178
- // Validate mutual exclusivity: --workdir vs --var repo
179
- const repoPath = vars.repo;
180
- if (workdir && repoPath) {
181
- console.error("--workdir and --var repo=... are mutually exclusive. Use one or the other.");
311
+ if (vars.repo) {
312
+ console.error("Error: --var repo is no longer supported. Use --workdir instead.");
182
313
  process.exit(1);
183
314
  }
184
315
  // Validate --workdir exists
185
- if (workdir) {
186
- const resolvedWorkdir = path.resolve(workdir);
187
- if (!fs.existsSync(resolvedWorkdir)) {
188
- console.error(`--workdir path does not exist: ${resolvedWorkdir}`);
189
- process.exit(1);
190
- }
191
- if (!fs.statSync(resolvedWorkdir).isDirectory()) {
192
- console.error(`--workdir path is not a directory: ${resolvedWorkdir}`);
193
- process.exit(1);
194
- }
316
+ const resolvedWorkdir = path.resolve(workdir);
317
+ if (!fs.existsSync(resolvedWorkdir)) {
318
+ console.error(`--workdir path does not exist: ${resolvedWorkdir}`);
319
+ process.exit(1);
320
+ }
321
+ if (!fs.statSync(resolvedWorkdir).isDirectory()) {
322
+ console.error(`--workdir path is not a directory: ${resolvedWorkdir}`);
323
+ process.exit(1);
195
324
  }
196
325
  // Validate callback flags after parsing (order-independent)
197
- if (noCallback && callbackUrl) {
198
- console.error("--no-callback and --callback-url are mutually exclusive.");
326
+ if (noCallback && (callbackUrl || callbackExec)) {
327
+ console.error("--no-callback is mutually exclusive with --callback-url and --callback-exec.");
199
328
  process.exit(1);
200
329
  }
201
- if (!noCallback && !callbackUrl) {
202
- console.error("Error: No callback configured. Use --callback-url to enable notifications, or --no-callback to run without.");
330
+ if (!noCallback && !callbackUrl && !callbackExec) {
331
+ console.error("Error: No callback configured. Use --callback-url and/or --callback-exec, or --no-callback to run without.");
203
332
  process.exit(1);
204
333
  }
205
334
  let callbackConfig;
@@ -217,65 +346,29 @@ async function main() {
217
346
  process.exit(1);
218
347
  }
219
348
  }
220
- const spec = await loadWorkflowSpec(action);
221
- // Determine the working directory for the run
222
- let effectiveWorkdir;
223
- let worktreeMetadata;
224
- if (workdir) {
225
- // --workdir provided: use it directly, no git operations
226
- effectiveWorkdir = path.resolve(workdir);
227
- }
228
- else if (repoPath) {
229
- const resolvedRepo = path.resolve(repoPath);
230
- if (noWorktree) {
231
- // --no-worktree: work directly in the repo, no worktree creation
232
- effectiveWorkdir = resolvedRepo;
349
+ if (modelFlagSeen) {
350
+ if (modelOverride === undefined) {
351
+ console.error("Invalid --model value: missing model name.");
352
+ process.exit(1);
233
353
  }
234
- else {
235
- // Auto-create worktree (default behavior when repo is provided)
236
- const repoInfo = validateGitRepo(resolvedRepo);
237
- if (repoInfo.type === 'not-git') {
238
- console.error(`Cannot auto-create worktree: ${resolvedRepo} is not a git repository. Use --workdir instead.`);
239
- process.exit(1);
240
- }
241
- const shortId = crypto.randomUUID().slice(0, 8);
242
- const branch = branchName || `soulforge/${shortId}`;
243
- console.log(`Creating worktree with branch: ${branch}`);
244
- try {
245
- const result = createWorktree(resolvedRepo, branch, repoInfo);
246
- worktreeMetadata = {
247
- originalRepo: resolvedRepo,
248
- worktreePath: result.worktreePath,
249
- branch: result.branch,
250
- };
251
- effectiveWorkdir = result.worktreePath;
252
- // Install npm dependencies if package.json exists
253
- const packageJsonPath = path.join(result.worktreePath, 'package.json');
254
- if (fs.existsSync(packageJsonPath)) {
255
- console.log("Installing npm dependencies in worktree...");
256
- try {
257
- execSync('npm install', { cwd: result.worktreePath, stdio: 'inherit' });
258
- }
259
- catch {
260
- console.warn("Warning: npm install failed in worktree");
261
- }
262
- }
263
- console.log(`Worktree created at: ${result.worktreePath}`);
264
- }
265
- catch (err) {
266
- console.error(err instanceof Error ? err.message : String(err));
267
- process.exit(1);
268
- }
354
+ const trimmedModel = modelOverride.trim();
355
+ if (trimmedModel.length === 0) {
356
+ console.error("Invalid --model value: model name cannot be empty.");
357
+ process.exit(1);
269
358
  }
270
- }
271
- // Set workdir variable for templates
272
- if (effectiveWorkdir) {
273
- vars.workdir = effectiveWorkdir;
274
- // Also keep repo pointing to the effective workdir for backwards compatibility
275
- if (repoPath) {
276
- vars.repo = effectiveWorkdir;
359
+ if (trimmedModel.startsWith("-")) {
360
+ console.error(`Invalid --model value "${modelOverride}": model name cannot start with '-'.`);
361
+ process.exit(1);
277
362
  }
363
+ modelOverride = trimmedModel;
278
364
  }
365
+ const workflowResolution = await resolveWorkflowInput(action);
366
+ const resolvedWorkflowPath = workflowResolution.path;
367
+ const spec = await loadWorkflowSpec(resolvedWorkflowPath);
368
+ // Determine the working directory for the run
369
+ const effectiveWorkdir = path.resolve(workdir);
370
+ // Set workdir variable for templates
371
+ vars.workdir = effectiveWorkdir;
279
372
  // Merge vars into context
280
373
  if (spec.context) {
281
374
  Object.assign(spec.context, vars);
@@ -283,31 +376,106 @@ async function main() {
283
376
  else {
284
377
  spec.context = vars;
285
378
  }
286
- // Ensure daemon is running
287
- const status = isDaemonRunning();
288
- if (!status.running) {
289
- console.log("Daemon not running, starting...");
290
- startDaemonBackground();
291
- // Give daemon a moment to start
292
- await new Promise((r) => setTimeout(r, 1000));
293
- }
294
- const run = createRun(spec, action, task, callbackConfig, worktreeMetadata, executorOverride);
379
+ // Ensure daemon is running (idempotent singleton auto-start).
380
+ startDaemonBackground();
381
+ // Give daemon a moment to start if it was just spawned.
382
+ await new Promise((r) => setTimeout(r, 1000));
383
+ const run = createRun(spec, resolvedWorkflowPath, task, callbackConfig, undefined, executorOverride, modelOverride, keepWorktree, callbackExec);
295
384
  insertEvent(run.id, "run_started", `Run started: "${task}"`);
296
385
  console.log(`Run: ${run.id}`);
297
386
  console.log(`Workflow: ${run.workflow_id}`);
298
387
  console.log(`Task: ${task}`);
299
- if (effectiveWorkdir) {
300
- console.log(`Workdir: ${effectiveWorkdir}`);
301
- }
388
+ console.log(`Workdir: ${effectiveWorkdir}`);
302
389
  console.log(`Status: ${run.status}`);
303
390
  console.log(`\nDaemon will pick up the first step shortly.`);
304
391
  console.log(`Track progress: soulforge status ${run.id.slice(0, 8)}`);
305
392
  return;
306
393
  }
394
+ // ── Complete command ────────────────────────────────────────────
395
+ if (group === "complete") {
396
+ const completeArgs = [action, ...rest].filter((arg) => arg !== undefined);
397
+ let runId;
398
+ let stepId;
399
+ let dataRaw;
400
+ for (let i = 0; i < completeArgs.length; i += 1) {
401
+ if (completeArgs[i] === "--run-id" && completeArgs[i + 1]) {
402
+ runId = completeArgs[i + 1];
403
+ i += 1;
404
+ }
405
+ else if (completeArgs[i] === "--step-id" && completeArgs[i + 1]) {
406
+ stepId = completeArgs[i + 1];
407
+ i += 1;
408
+ }
409
+ else if (completeArgs[i] === "--data" && completeArgs[i + 1]) {
410
+ dataRaw = completeArgs[i + 1];
411
+ i += 1;
412
+ }
413
+ }
414
+ if (!runId || !stepId || dataRaw === undefined) {
415
+ console.error("Missing required flags. Usage: soulforge complete --run-id <id> --step-id <id> --data '<json>'");
416
+ process.exit(1);
417
+ }
418
+ let data;
419
+ try {
420
+ data = JSON.parse(dataRaw);
421
+ }
422
+ catch {
423
+ console.error("Invalid JSON for --data.");
424
+ process.exit(1);
425
+ }
426
+ const run = getRun(runId);
427
+ if (!run) {
428
+ console.error(`Run not found: ${runId}`);
429
+ process.exit(1);
430
+ }
431
+ const db = getDb();
432
+ const step = db.prepare("SELECT id, step_id, output_schema, status FROM steps WHERE run_id = ? AND (step_id = ? OR id = ?) LIMIT 1").get(run.id, stepId, stepId);
433
+ if (!step) {
434
+ console.error(`Step not found for run ${run.id}: ${stepId}`);
435
+ process.exit(1);
436
+ }
437
+ if (!step.output_schema) {
438
+ console.error(`Step "${step.step_id}" does not define output_schema. soulforge complete only supports structured-output steps.`);
439
+ process.exit(1);
440
+ }
441
+ if (step.status !== "running") {
442
+ console.error(`Step "${step.step_id}" is not running (status: ${step.status}).`);
443
+ process.exit(1);
444
+ }
445
+ const runningStep = db.prepare("SELECT id FROM steps WHERE run_id = ? AND status = 'running' AND step_id = ? LIMIT 1").get(run.id, step.step_id);
446
+ if (!runningStep || runningStep.id !== step.id) {
447
+ console.error(`Step "${step.step_id}" is not the running step for this run.`);
448
+ process.exit(1);
449
+ }
450
+ let schema;
451
+ try {
452
+ schema = JSON.parse(step.output_schema);
453
+ }
454
+ catch {
455
+ console.error(`Stored output schema for step "${step.step_id}" is invalid JSON.`);
456
+ process.exit(1);
457
+ }
458
+ const validation = validateOutput(data, schema);
459
+ if (!validation.valid) {
460
+ console.error("Output validation failed:");
461
+ for (const error of validation.errors) {
462
+ console.error(error);
463
+ }
464
+ process.exit(1);
465
+ }
466
+ updateStepStatus(step.id, "done", { output: JSON.stringify(data) });
467
+ const runContext = JSON.parse(run.context);
468
+ if (isPlainObject(data)) {
469
+ updateRunContext(run.id, { ...runContext, ...data });
470
+ }
471
+ console.log(`Completed step "${step.step_id}" for run ${run.id}.`);
472
+ return;
473
+ }
307
474
  // ── Status command ──────────────────────────────────────────────
308
475
  if (group === "status") {
309
476
  const query = [action, ...rest].filter(Boolean).join(" ");
310
477
  if (!query) {
478
+ console.log(formatDaemonStatusLine());
311
479
  // Show summary of active runs
312
480
  const runs = listRuns(10);
313
481
  const active = runs.filter((r) => r.status === "running" || r.status === "pending");
@@ -332,7 +500,7 @@ async function main() {
332
500
  return;
333
501
  }
334
502
  const steps = getStepsForRun(run.id);
335
- const stories = getStoriesForRun(run.id);
503
+ const loopItems = getLoopItemsForRun(run.id);
336
504
  console.log(`Run: ${run.id}`);
337
505
  console.log(`Workflow: ${run.workflow_id}`);
338
506
  console.log(`Task: ${run.task.slice(0, 120)}`);
@@ -346,13 +514,13 @@ async function main() {
346
514
  const model = s.model ? ` [${s.model}]` : "";
347
515
  console.log(` [${s.status.padEnd(17)}] ${s.step_id} (${s.executor})${model}${dur}`);
348
516
  }
349
- if (stories.length > 0) {
350
- const done = stories.filter((s) => s.status === "done").length;
351
- const failed = stories.filter((s) => s.status === "failed").length;
517
+ if (loopItems.length > 0) {
518
+ const done = loopItems.filter((s) => s.status === "done").length;
519
+ const failed = loopItems.filter((s) => s.status === "failed").length;
352
520
  console.log();
353
- console.log(`Stories: ${done}/${stories.length} done${failed ? `, ${failed} failed` : ""}`);
354
- for (const s of stories) {
355
- console.log(` ${s.story_id.padEnd(10)} [${s.status.padEnd(7)}] ${s.title}`);
521
+ console.log(`Stories: ${done}/${loopItems.length} done${failed ? `, ${failed} failed` : ""}`);
522
+ for (const s of loopItems) {
523
+ console.log(` ${s.item_id.padEnd(10)} [${s.status.padEnd(7)}] ${s.title}`);
356
524
  }
357
525
  }
358
526
  return;
@@ -394,6 +562,44 @@ async function main() {
394
562
  // Approve — mark step done and advance
395
563
  updateStepStatus(step.id, "done", { output: `APPROVED: ${message}` });
396
564
  insertEvent(run.id, "step_complete", `Checkpoint "${step.step_id}" approved: ${message}`, step.step_id);
565
+ if (step.type === "gate" && step.gate_config) {
566
+ const gateConfig = JSON.parse(step.gate_config);
567
+ const runContext = JSON.parse(run.context);
568
+ const loopCounts = parseGateLoopCounts(runContext.__gate_loop_counts);
569
+ const gateLoopCount = (loopCounts[step.step_id] ?? 0) + 1;
570
+ loopCounts[step.step_id] = gateLoopCount;
571
+ const contextWithLoopCount = {
572
+ ...runContext,
573
+ __gate_loop_counts: JSON.stringify(loopCounts),
574
+ };
575
+ updateRunContext(run.id, contextWithLoopCount);
576
+ if (gateLoopCount > gateConfig.max_loops) {
577
+ const error = `Gate step "${step.step_id}" exceeded max_loops (${gateConfig.max_loops})`;
578
+ updateStepStatus(step.id, "failed", { error });
579
+ updateRunStatus(run.id, "failed");
580
+ insertEvent(run.id, "step_failed", `Step "${step.step_id}" failed: ${error}`, step.step_id, error);
581
+ insertEvent(run.id, "run_failed", `Run failed at step "${step.step_id}"`, step.step_id, error);
582
+ console.error(error);
583
+ process.exit(1);
584
+ }
585
+ const decisionValue = getContextValue(contextWithLoopCount, gateConfig.decision_var);
586
+ const routeStepId = resolveGateRoute(gateConfig, decisionValue);
587
+ const target = db.prepare("SELECT id FROM steps WHERE run_id = ? AND step_id = ? LIMIT 1").get(run.id, routeStepId);
588
+ if (!target) {
589
+ const error = `Gate step "${step.step_id}" resolved route "${routeStepId}" but target step was not found`;
590
+ updateStepStatus(step.id, "failed", { error });
591
+ updateRunStatus(run.id, "failed");
592
+ insertEvent(run.id, "step_failed", `Step "${step.step_id}" failed: ${error}`, step.step_id, error);
593
+ insertEvent(run.id, "run_failed", `Run failed at step "${step.step_id}"`, step.step_id, error);
594
+ console.error(error);
595
+ process.exit(1);
596
+ }
597
+ updateStepStatus(target.id, "pending");
598
+ const detail = `decision_var=${gateConfig.decision_var}, value=${decisionValue ?? "(missing)"}, target=${routeStepId}, loop=${gateLoopCount}/${gateConfig.max_loops}`;
599
+ insertEvent(run.id, "gate_routed", `Gate "${step.step_id}" routed to "${routeStepId}"`, step.step_id, detail);
600
+ console.log(`Checkpoint "${step.step_id}" approved. Routed to "${routeStepId}".`);
601
+ return;
602
+ }
397
603
  const { runCompleted } = advancePipeline(run.id);
398
604
  if (runCompleted) {
399
605
  insertEvent(run.id, "run_complete", `Run completed: "${run.task}"`);
@@ -482,9 +688,9 @@ async function main() {
482
688
  // Reset step to pending
483
689
  updateStepStatus(failedStep.id, "pending", { error: null, current_story_id: null });
484
690
  updateRunStatus(run.id, "running");
485
- // If loop step, reset failed stories
691
+ // If loop step, reset failed loop_items
486
692
  if (failedStep.type === "loop") {
487
- db.prepare("UPDATE stories SET status = 'pending', updated_at = ? WHERE run_id = ? AND status = 'failed'").run(new Date().toISOString(), run.id);
693
+ db.prepare("UPDATE loop_items SET status = 'pending', updated_at = ? WHERE run_id = ? AND status = 'failed'").run(new Date().toISOString(), run.id);
488
694
  }
489
695
  insertEvent(run.id, "run_resumed", `Run resumed from step "${failedStep.step_id}"`);
490
696
  console.log(`Resumed run ${run.id.slice(0, 8)} from step "${failedStep.step_id}".`);