@hasna/loops 0.3.5 → 0.3.7

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/dist/cli/index.js CHANGED
@@ -236,6 +236,82 @@ function parseDuration(input) {
236
236
  return Math.round(value * multiplier);
237
237
  }
238
238
 
239
+ // src/lib/goal/types.ts
240
+ var GOAL_TERMINAL = ["budgetLimited", "complete", "cancelled"];
241
+ var GOAL_OBJECTIVE_MAX_CHARS = 4000;
242
+
243
+ // src/lib/goal/status.ts
244
+ function isTerminal(status) {
245
+ return GOAL_TERMINAL.includes(status);
246
+ }
247
+ function nodeBudgetExhausted(node) {
248
+ return node.tokenBudget !== undefined && node.tokensUsed >= node.tokenBudget;
249
+ }
250
+ function readyNodeKeys(plan) {
251
+ if (plan.status !== "active")
252
+ return [];
253
+ const byKey = new Map(plan.nodes.map((node) => [node.key, node]));
254
+ return plan.nodes.filter((node) => {
255
+ if (node.status !== "pending")
256
+ return false;
257
+ if (nodeBudgetExhausted(node))
258
+ return false;
259
+ return node.dependsOn.every((dependency) => byKey.get(dependency)?.status === "complete");
260
+ }).sort((a, b) => b.priority - a.priority || a.sequence - b.sequence || a.key.localeCompare(b.key)).map((node) => node.key);
261
+ }
262
+ function rollupSummary(nodes) {
263
+ const rollup = {
264
+ total: nodes.length,
265
+ pending: 0,
266
+ active: 0,
267
+ paused: 0,
268
+ blocked: 0,
269
+ usageLimited: 0,
270
+ budgetLimited: 0,
271
+ complete: 0,
272
+ cancelled: 0
273
+ };
274
+ for (const node of nodes) {
275
+ if (node.status in rollup) {
276
+ rollup[node.status] += 1;
277
+ }
278
+ }
279
+ return rollup;
280
+ }
281
+ function assertGoalTransition(from, to) {
282
+ if (isTerminal(from) && from !== to) {
283
+ throw new Error(`cannot transition terminal goal status ${from} to ${to}`);
284
+ }
285
+ }
286
+ function assertAcyclicNodes(nodes) {
287
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
288
+ const visiting = new Set;
289
+ const visited = new Set;
290
+ function visit(key) {
291
+ if (visited.has(key))
292
+ return;
293
+ if (visiting.has(key))
294
+ throw new Error(`goal dependency cycle includes node ${key}`);
295
+ const node = byKey.get(key);
296
+ if (!node)
297
+ throw new Error(`goal plan references missing node ${key}`);
298
+ visiting.add(key);
299
+ for (const dependency of node.dependsOn) {
300
+ if (!byKey.has(dependency))
301
+ throw new Error(`goal node ${key} depends on missing node ${dependency}`);
302
+ visit(dependency);
303
+ }
304
+ visiting.delete(key);
305
+ visited.add(key);
306
+ }
307
+ for (const node of nodes)
308
+ visit(node.key);
309
+ }
310
+ function updateReadyFlags(nodes, planStatus) {
311
+ const ready = new Set(readyNodeKeys({ status: planStatus, nodes }));
312
+ return nodes.map((node) => ({ ...node, ready: ready.has(node.key) }));
313
+ }
314
+
239
315
  // src/lib/workflow-spec.ts
240
316
  function assertObject(value, label) {
241
317
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -245,6 +321,35 @@ function assertString(value, label) {
245
321
  if (typeof value !== "string" || value.trim() === "")
246
322
  throw new Error(`${label} must be a non-empty string`);
247
323
  }
324
+ function optionalPositiveInteger(value, label) {
325
+ if (value === undefined)
326
+ return;
327
+ if (!Number.isInteger(value) || value <= 0)
328
+ throw new Error(`${label} must be a positive integer`);
329
+ return value;
330
+ }
331
+ function normalizeGoalSpec(value, label = "goal") {
332
+ if (value === undefined)
333
+ return;
334
+ assertObject(value, label);
335
+ assertString(value.objective, `${label}.objective`);
336
+ const objective = value.objective.trim();
337
+ if (objective.length > GOAL_OBJECTIVE_MAX_CHARS) {
338
+ throw new Error(`${label}.objective must be ${GOAL_OBJECTIVE_MAX_CHARS} characters or fewer`);
339
+ }
340
+ const autoExecute = value.autoExecute === undefined ? undefined : String(value.autoExecute);
341
+ if (autoExecute !== undefined && !["off", "readyOnly", "aiDirected"].includes(autoExecute)) {
342
+ throw new Error(`${label}.autoExecute must be off, readyOnly, or aiDirected`);
343
+ }
344
+ return {
345
+ objective,
346
+ tokenBudget: optionalPositiveInteger(value.tokenBudget, `${label}.tokenBudget`),
347
+ maxTurns: optionalPositiveInteger(value.maxTurns, `${label}.maxTurns`),
348
+ maxTokens: optionalPositiveInteger(value.maxTokens, `${label}.maxTokens`),
349
+ model: typeof value.model === "string" && value.model.trim() ? value.model.trim() : undefined,
350
+ autoExecute
351
+ };
352
+ }
248
353
  function validateTarget(value, label) {
249
354
  assertObject(value, label);
250
355
  if (value.type === "command") {
@@ -271,6 +376,7 @@ function validateTarget(value, label) {
271
376
  }
272
377
  function normalizeCreateWorkflowInput(input) {
273
378
  assertString(input.name, "workflow.name");
379
+ const goal = normalizeGoalSpec(input.goal, "goal");
274
380
  if (!Array.isArray(input.steps) || input.steps.length === 0)
275
381
  throw new Error("workflow.steps must contain at least one step");
276
382
  const seen = new Set;
@@ -283,6 +389,7 @@ function normalizeCreateWorkflowInput(input) {
283
389
  return {
284
390
  ...step,
285
391
  id: step.id,
392
+ goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
286
393
  target: validateTarget(step.target, `workflow.steps[${index}].target`),
287
394
  dependsOn: step.dependsOn ?? [],
288
395
  continueOnFailure: step.continueOnFailure ?? false
@@ -297,7 +404,7 @@ function normalizeCreateWorkflowInput(input) {
297
404
  }
298
405
  }
299
406
  workflowExecutionOrder({ steps });
300
- return { ...input, name: input.name.trim(), version: input.version ?? 1, steps };
407
+ return { ...input, name: input.name.trim(), goal, version: input.version ?? 1, steps };
301
408
  }
302
409
  function workflowExecutionOrder(workflow) {
303
410
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
@@ -333,6 +440,7 @@ function workflowBodyFromJson(value, fallbackName) {
333
440
  return normalizeCreateWorkflowInput({
334
441
  name: rawName,
335
442
  description: typeof value.description === "string" ? value.description : undefined,
443
+ goal: normalizeGoalSpec(value.goal, "goal"),
336
444
  version: typeof value.version === "number" ? value.version : undefined,
337
445
  steps: value.steps
338
446
  });
@@ -347,6 +455,7 @@ function rowToLoop(row) {
347
455
  status: row.status,
348
456
  schedule: JSON.parse(row.schedule_json),
349
457
  target: JSON.parse(row.target_json),
458
+ goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
350
459
  machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
351
460
  nextRunAt: row.next_run_at ?? undefined,
352
461
  retryScheduledFor: row.retry_scheduled_for ?? undefined,
@@ -379,6 +488,7 @@ function rowToRun(row) {
379
488
  stdout: row.stdout ?? undefined,
380
489
  stderr: row.stderr ?? undefined,
381
490
  error: row.error ?? undefined,
491
+ goalRunId: row.goal_run_id ?? undefined,
382
492
  createdAt: row.created_at,
383
493
  updatedAt: row.updated_at
384
494
  };
@@ -390,6 +500,7 @@ function rowToWorkflow(row) {
390
500
  description: row.description ?? undefined,
391
501
  version: row.version,
392
502
  status: row.status,
503
+ goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
393
504
  steps: JSON.parse(row.steps_json),
394
505
  createdAt: row.created_at,
395
506
  updatedAt: row.updated_at
@@ -409,6 +520,7 @@ function rowToWorkflowRun(row) {
409
520
  finishedAt: row.finished_at ?? undefined,
410
521
  durationMs: row.duration_ms ?? undefined,
411
522
  error: row.error ?? undefined,
523
+ goalRunId: row.goal_run_id ?? undefined,
412
524
  createdAt: row.created_at,
413
525
  updatedAt: row.updated_at
414
526
  };
@@ -430,6 +542,68 @@ function rowToWorkflowStepRun(row) {
430
542
  error: row.error ?? undefined,
431
543
  accountProfile: row.account_profile ?? undefined,
432
544
  accountTool: row.account_tool ?? undefined,
545
+ goalRunId: row.goal_run_id ?? undefined,
546
+ createdAt: row.created_at,
547
+ updatedAt: row.updated_at
548
+ };
549
+ }
550
+ function rowToGoal(row) {
551
+ return {
552
+ goalId: row.id,
553
+ planId: row.plan_id,
554
+ objective: row.objective,
555
+ status: row.status,
556
+ tokenBudget: row.token_budget ?? undefined,
557
+ tokensUsed: row.tokens_used,
558
+ timeUsedSeconds: row.time_used_seconds,
559
+ autoExecute: row.auto_execute,
560
+ maxTokens: row.max_tokens ?? undefined,
561
+ sourceType: row.source_type ?? undefined,
562
+ sourceId: row.source_id ?? undefined,
563
+ loopId: row.loop_id ?? undefined,
564
+ loopRunId: row.loop_run_id ?? undefined,
565
+ workflowId: row.workflow_id ?? undefined,
566
+ workflowRunId: row.workflow_run_id ?? undefined,
567
+ workflowStepId: row.workflow_step_id ?? undefined,
568
+ createdAt: row.created_at,
569
+ updatedAt: row.updated_at
570
+ };
571
+ }
572
+ function rowToGoalPlanNode(row) {
573
+ return {
574
+ nodeId: row.id,
575
+ planId: row.plan_id,
576
+ key: row.key,
577
+ sequence: row.sequence,
578
+ priority: row.priority,
579
+ objective: row.objective,
580
+ status: row.status,
581
+ ready: row.ready === 1,
582
+ tokenBudget: row.token_budget ?? undefined,
583
+ tokensUsed: row.tokens_used,
584
+ timeUsedSeconds: row.time_used_seconds,
585
+ dependsOn: JSON.parse(row.depends_on_json),
586
+ createdAt: row.created_at,
587
+ updatedAt: row.updated_at
588
+ };
589
+ }
590
+ function rowToGoalRun(row) {
591
+ return {
592
+ runId: row.id,
593
+ goalId: row.goal_id,
594
+ planId: row.plan_id,
595
+ loopId: row.loop_id ?? undefined,
596
+ loopRunId: row.loop_run_id ?? undefined,
597
+ workflowId: row.workflow_id ?? undefined,
598
+ workflowRunId: row.workflow_run_id ?? undefined,
599
+ workflowStepId: row.workflow_step_id ?? undefined,
600
+ turn: row.turn,
601
+ phase: row.phase,
602
+ status: row.status,
603
+ nodeKey: row.node_key ?? undefined,
604
+ tokensUsed: row.tokens_used,
605
+ evidence: row.evidence_json ? JSON.parse(row.evidence_json) : undefined,
606
+ rawResponse: row.raw_response_json ? JSON.parse(row.raw_response_json) : undefined,
433
607
  createdAt: row.created_at,
434
608
  updatedAt: row.updated_at
435
609
  };
@@ -490,6 +664,7 @@ class Store {
490
664
  status TEXT NOT NULL,
491
665
  schedule_json TEXT NOT NULL,
492
666
  target_json TEXT NOT NULL,
667
+ goal_json TEXT,
493
668
  machine_json TEXT,
494
669
  next_run_at TEXT,
495
670
  retry_scheduled_for TEXT,
@@ -523,6 +698,7 @@ class Store {
523
698
  stdout TEXT,
524
699
  stderr TEXT,
525
700
  error TEXT,
701
+ goal_run_id TEXT,
526
702
  created_at TEXT NOT NULL,
527
703
  updated_at TEXT NOT NULL,
528
704
  UNIQUE(loop_id, scheduled_for)
@@ -547,6 +723,7 @@ class Store {
547
723
  description TEXT,
548
724
  version INTEGER NOT NULL,
549
725
  status TEXT NOT NULL,
726
+ goal_json TEXT,
550
727
  steps_json TEXT NOT NULL,
551
728
  created_at TEXT NOT NULL,
552
729
  updated_at TEXT NOT NULL
@@ -567,6 +744,7 @@ class Store {
567
744
  finished_at TEXT,
568
745
  duration_ms INTEGER,
569
746
  error TEXT,
747
+ goal_run_id TEXT,
570
748
  created_at TEXT NOT NULL,
571
749
  updated_at TEXT NOT NULL
572
750
  );
@@ -593,6 +771,7 @@ class Store {
593
771
  error TEXT,
594
772
  account_profile TEXT,
595
773
  account_tool TEXT,
774
+ goal_run_id TEXT,
596
775
  created_at TEXT NOT NULL,
597
776
  updated_at TEXT NOT NULL,
598
777
  UNIQUE(workflow_run_id, step_id)
@@ -611,15 +790,100 @@ class Store {
611
790
  UNIQUE(workflow_run_id, sequence)
612
791
  );
613
792
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
793
+
794
+ CREATE TABLE IF NOT EXISTS goals (
795
+ id TEXT PRIMARY KEY,
796
+ plan_id TEXT NOT NULL,
797
+ objective TEXT NOT NULL,
798
+ status TEXT NOT NULL,
799
+ token_budget INTEGER,
800
+ tokens_used INTEGER NOT NULL,
801
+ time_used_seconds INTEGER NOT NULL,
802
+ auto_execute TEXT NOT NULL,
803
+ max_tokens INTEGER,
804
+ source_type TEXT,
805
+ source_id TEXT,
806
+ loop_id TEXT,
807
+ loop_run_id TEXT,
808
+ workflow_id TEXT,
809
+ workflow_run_id TEXT,
810
+ workflow_step_id TEXT,
811
+ created_at TEXT NOT NULL,
812
+ updated_at TEXT NOT NULL
813
+ );
814
+ CREATE INDEX IF NOT EXISTS idx_goals_status_updated ON goals(status, updated_at DESC);
815
+ CREATE INDEX IF NOT EXISTS idx_goals_loop_run ON goals(loop_run_id);
816
+ CREATE INDEX IF NOT EXISTS idx_goals_workflow_run ON goals(workflow_run_id);
817
+ CREATE INDEX IF NOT EXISTS idx_goals_source ON goals(source_type, source_id);
818
+
819
+ CREATE TABLE IF NOT EXISTS goal_plan_nodes (
820
+ id TEXT PRIMARY KEY,
821
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
822
+ plan_id TEXT NOT NULL,
823
+ key TEXT NOT NULL,
824
+ sequence INTEGER NOT NULL,
825
+ priority INTEGER NOT NULL,
826
+ objective TEXT NOT NULL,
827
+ status TEXT NOT NULL,
828
+ ready INTEGER NOT NULL,
829
+ token_budget INTEGER,
830
+ tokens_used INTEGER NOT NULL,
831
+ time_used_seconds INTEGER NOT NULL,
832
+ depends_on_json TEXT NOT NULL,
833
+ created_at TEXT NOT NULL,
834
+ updated_at TEXT NOT NULL,
835
+ UNIQUE(plan_id, key)
836
+ );
837
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_goal_sequence ON goal_plan_nodes(goal_id, sequence);
838
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_status ON goal_plan_nodes(status);
839
+
840
+ CREATE TABLE IF NOT EXISTS goal_runs (
841
+ id TEXT PRIMARY KEY,
842
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
843
+ plan_id TEXT NOT NULL,
844
+ loop_id TEXT,
845
+ loop_run_id TEXT,
846
+ workflow_id TEXT,
847
+ workflow_run_id TEXT,
848
+ workflow_step_id TEXT,
849
+ turn INTEGER NOT NULL,
850
+ phase TEXT NOT NULL,
851
+ status TEXT NOT NULL,
852
+ node_key TEXT,
853
+ tokens_used INTEGER NOT NULL,
854
+ evidence_json TEXT,
855
+ raw_response_json TEXT,
856
+ created_at TEXT NOT NULL,
857
+ updated_at TEXT NOT NULL
858
+ );
859
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_goal_created ON goal_runs(goal_id, created_at);
860
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_loop_run ON goal_runs(loop_run_id);
861
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_workflow_run ON goal_runs(workflow_run_id);
614
862
  `);
615
863
  try {
616
864
  this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
617
865
  } catch {}
866
+ try {
867
+ this.db.query("ALTER TABLE loops ADD COLUMN goal_json TEXT").run();
868
+ } catch {}
869
+ try {
870
+ this.db.query("ALTER TABLE loop_runs ADD COLUMN goal_run_id TEXT").run();
871
+ } catch {}
872
+ try {
873
+ this.db.query("ALTER TABLE workflow_specs ADD COLUMN goal_json TEXT").run();
874
+ } catch {}
875
+ try {
876
+ this.db.query("ALTER TABLE workflow_runs ADD COLUMN goal_run_id TEXT").run();
877
+ } catch {}
618
878
  try {
619
879
  this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
620
880
  } catch {}
881
+ try {
882
+ this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN goal_run_id TEXT").run();
883
+ } catch {}
621
884
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
622
885
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
886
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
623
887
  }
624
888
  assertDaemonLeaseFence(opts = {}, now = nowIso()) {
625
889
  if (!opts.daemonLeaseId)
@@ -637,6 +901,7 @@ class Store {
637
901
  status: "active",
638
902
  schedule: input.schedule,
639
903
  target: input.target,
904
+ goal: input.goal,
640
905
  machine: input.machine,
641
906
  nextRunAt: initialNextRun(input.schedule, from),
642
907
  catchUp: input.catchUp ?? "latest",
@@ -650,8 +915,8 @@ class Store {
650
915
  updatedAt: now
651
916
  };
652
917
  this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
653
- catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
654
- VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
918
+ goal_json, catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
919
+ VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $goal, $catchUp, $catchUpLimit,
655
920
  $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
656
921
  $id: loop.id,
657
922
  $name: loop.name,
@@ -660,6 +925,7 @@ class Store {
660
925
  $schedule: JSON.stringify(loop.schedule),
661
926
  $target: JSON.stringify(loop.target),
662
927
  $machine: loop.machine ? JSON.stringify(loop.machine) : null,
928
+ $goal: loop.goal ? JSON.stringify(loop.goal) : null,
663
929
  $nextRun: loop.nextRunAt ?? null,
664
930
  $catchUp: loop.catchUp,
665
931
  $catchUpLimit: loop.catchUpLimit,
@@ -739,17 +1005,19 @@ class Store {
739
1005
  description: normalized.description,
740
1006
  version: normalized.version ?? 1,
741
1007
  status: "active",
1008
+ goal: normalized.goal,
742
1009
  steps: normalized.steps,
743
1010
  createdAt: now,
744
1011
  updatedAt: now
745
1012
  };
746
- this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, steps_json, created_at, updated_at)
747
- VALUES ($id, $name, $description, $version, $status, $steps, $created, $updated)`).run({
1013
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1014
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
748
1015
  $id: workflow.id,
749
1016
  $name: workflow.name,
750
1017
  $description: workflow.description ?? null,
751
1018
  $version: workflow.version,
752
1019
  $status: workflow.status,
1020
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
753
1021
  $steps: JSON.stringify(workflow.steps),
754
1022
  $created: workflow.createdAt,
755
1023
  $updated: workflow.updatedAt
@@ -783,6 +1051,277 @@ class Store {
783
1051
  throw new Error(`workflow not found after archive: ${workflow.id}`);
784
1052
  return archived;
785
1053
  }
1054
+ createGoal(input, opts = {}) {
1055
+ const now = nowIso();
1056
+ this.db.exec("BEGIN IMMEDIATE");
1057
+ try {
1058
+ this.assertDaemonLeaseFence(opts, now);
1059
+ const id = genId();
1060
+ this.db.query(`INSERT INTO goals (id, plan_id, objective, status, token_budget, tokens_used, time_used_seconds, auto_execute,
1061
+ max_tokens, source_type, source_id, loop_id, loop_run_id, workflow_id, workflow_run_id, workflow_step_id,
1062
+ created_at, updated_at)
1063
+ VALUES ($id, $planId, $objective, 'active', $tokenBudget, 0, 0, $autoExecute, $maxTokens, $sourceType,
1064
+ $sourceId, $loopId, $loopRunId, $workflowId, $workflowRunId, $workflowStepId, $created, $updated)`).run({
1065
+ $id: id,
1066
+ $planId: id,
1067
+ $objective: input.objective,
1068
+ $tokenBudget: input.tokenBudget ?? null,
1069
+ $autoExecute: input.autoExecute ?? "readyOnly",
1070
+ $maxTokens: input.maxTokens ?? input.tokenBudget ?? null,
1071
+ $sourceType: input.sourceType ?? null,
1072
+ $sourceId: input.sourceId ?? null,
1073
+ $loopId: input.loopId ?? null,
1074
+ $loopRunId: input.loopRunId ?? null,
1075
+ $workflowId: input.workflowId ?? null,
1076
+ $workflowRunId: input.workflowRunId ?? null,
1077
+ $workflowStepId: input.workflowStepId ?? null,
1078
+ $created: now,
1079
+ $updated: now
1080
+ });
1081
+ this.db.exec("COMMIT");
1082
+ return this.requireGoal(id);
1083
+ } catch (error) {
1084
+ try {
1085
+ this.db.exec("ROLLBACK");
1086
+ } catch {}
1087
+ throw error;
1088
+ }
1089
+ }
1090
+ getGoal(id) {
1091
+ const row = this.db.query("SELECT * FROM goals WHERE id = ?").get(id);
1092
+ return row ? rowToGoal(row) : undefined;
1093
+ }
1094
+ requireGoal(id) {
1095
+ const goal = this.getGoal(id);
1096
+ if (!goal)
1097
+ throw new Error(`goal not found: ${id}`);
1098
+ return goal;
1099
+ }
1100
+ findGoalByLoop(idOrName) {
1101
+ const loop = this.getLoop(idOrName) ?? this.findLoopByName(idOrName);
1102
+ if (!loop)
1103
+ return;
1104
+ const row = this.db.query("SELECT * FROM goals WHERE loop_id = ? ORDER BY created_at DESC LIMIT 1").get(loop.id);
1105
+ return row ? rowToGoal(row) : undefined;
1106
+ }
1107
+ findGoalByRunId(id) {
1108
+ const direct = this.getGoal(id);
1109
+ if (direct)
1110
+ return direct;
1111
+ const event = this.db.query("SELECT * FROM goal_runs WHERE id = ?").get(id);
1112
+ if (event)
1113
+ return this.getGoal(event.goal_id);
1114
+ const row = this.db.query(`SELECT * FROM goals
1115
+ WHERE loop_run_id = ? OR workflow_run_id = ? OR workflow_step_id = ?
1116
+ ORDER BY created_at DESC LIMIT 1`).get(id, id, id);
1117
+ return row ? rowToGoal(row) : undefined;
1118
+ }
1119
+ findGoalByContext(context) {
1120
+ if (context.loopRunId) {
1121
+ const row = this.db.query(`SELECT * FROM goals
1122
+ WHERE loop_run_id = ? AND (? IS NULL OR workflow_step_id = ?)
1123
+ ORDER BY created_at DESC LIMIT 1`).get(context.loopRunId, context.workflowStepId ?? null, context.workflowStepId ?? null);
1124
+ if (row)
1125
+ return rowToGoal(row);
1126
+ }
1127
+ if (context.workflowRunId) {
1128
+ const row = this.db.query(`SELECT * FROM goals
1129
+ WHERE workflow_run_id = ? AND (? IS NULL OR workflow_step_id = ?)
1130
+ ORDER BY created_at DESC LIMIT 1`).get(context.workflowRunId, context.workflowStepId ?? null, context.workflowStepId ?? null);
1131
+ if (row)
1132
+ return rowToGoal(row);
1133
+ }
1134
+ if (context.sourceType && context.sourceId) {
1135
+ const row = this.db.query("SELECT * FROM goals WHERE source_type = ? AND source_id = ? ORDER BY created_at DESC LIMIT 1").get(context.sourceType, context.sourceId);
1136
+ if (row)
1137
+ return rowToGoal(row);
1138
+ }
1139
+ return;
1140
+ }
1141
+ listGoals(opts = {}) {
1142
+ const limit = opts.limit ?? 100;
1143
+ const rows = opts.status ? this.db.query("SELECT * FROM goals WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM goals ORDER BY created_at DESC LIMIT ?").all(limit);
1144
+ return rows.map(rowToGoal);
1145
+ }
1146
+ createGoalPlanNodes(goalId, nodes, opts = {}) {
1147
+ const goal = this.requireGoal(goalId);
1148
+ const now = nowIso();
1149
+ const materialized = nodes.map((node, sequence) => ({
1150
+ nodeId: genId(),
1151
+ planId: goal.planId,
1152
+ key: node.key,
1153
+ sequence,
1154
+ priority: node.priority ?? 0,
1155
+ objective: node.objective,
1156
+ status: "pending",
1157
+ ready: false,
1158
+ tokenBudget: node.tokenBudget,
1159
+ tokensUsed: 0,
1160
+ timeUsedSeconds: 0,
1161
+ dependsOn: node.dependsOn ?? [],
1162
+ createdAt: now,
1163
+ updatedAt: now
1164
+ }));
1165
+ const withReady = updateReadyFlags(materialized, "active");
1166
+ this.db.exec("BEGIN IMMEDIATE");
1167
+ try {
1168
+ this.assertDaemonLeaseFence(opts, now);
1169
+ for (const node of withReady) {
1170
+ this.db.query(`INSERT OR IGNORE INTO goal_plan_nodes (id, goal_id, plan_id, key, sequence, priority, objective, status, ready,
1171
+ token_budget, tokens_used, time_used_seconds, depends_on_json, created_at, updated_at)
1172
+ VALUES ($id, $goalId, $planId, $key, $sequence, $priority, $objective, $status, $ready, $tokenBudget,
1173
+ $tokensUsed, $timeUsedSeconds, $dependsOn, $created, $updated)`).run({
1174
+ $id: node.nodeId,
1175
+ $goalId: goal.goalId,
1176
+ $planId: goal.planId,
1177
+ $key: node.key,
1178
+ $sequence: node.sequence,
1179
+ $priority: node.priority,
1180
+ $objective: node.objective,
1181
+ $status: node.status,
1182
+ $ready: node.ready ? 1 : 0,
1183
+ $tokenBudget: node.tokenBudget ?? null,
1184
+ $tokensUsed: node.tokensUsed,
1185
+ $timeUsedSeconds: node.timeUsedSeconds,
1186
+ $dependsOn: JSON.stringify(node.dependsOn),
1187
+ $created: node.createdAt,
1188
+ $updated: node.updatedAt
1189
+ });
1190
+ }
1191
+ this.db.exec("COMMIT");
1192
+ } catch (error) {
1193
+ try {
1194
+ this.db.exec("ROLLBACK");
1195
+ } catch {}
1196
+ throw error;
1197
+ }
1198
+ return this.listGoalPlanNodes(goalId);
1199
+ }
1200
+ listGoalPlanNodes(goalIdOrPlanId) {
1201
+ const rows = this.db.query("SELECT * FROM goal_plan_nodes WHERE goal_id = ? OR plan_id = ? ORDER BY sequence ASC").all(goalIdOrPlanId, goalIdOrPlanId);
1202
+ return rows.map(rowToGoalPlanNode);
1203
+ }
1204
+ updateGoalStatus(goalId, status, opts = {}) {
1205
+ const current = this.requireGoal(goalId);
1206
+ assertGoalTransition(current.status, status);
1207
+ const now = (opts.now ?? new Date).toISOString();
1208
+ this.db.query(`UPDATE goals SET status=$status, updated_at=$updated
1209
+ WHERE id=$id
1210
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1211
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1212
+ ))`).run({
1213
+ $id: goalId,
1214
+ $status: status,
1215
+ $updated: now,
1216
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1217
+ $now: now
1218
+ });
1219
+ return this.requireGoal(goalId);
1220
+ }
1221
+ addGoalUsage(goalId, tokens, timeUsedSeconds = 0, opts = {}) {
1222
+ const now = (opts.now ?? new Date).toISOString();
1223
+ this.db.query(`UPDATE goals
1224
+ SET tokens_used=tokens_used + $tokens, time_used_seconds=time_used_seconds + $seconds, updated_at=$updated
1225
+ WHERE id=$id
1226
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1227
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1228
+ ))`).run({
1229
+ $id: goalId,
1230
+ $tokens: tokens,
1231
+ $seconds: timeUsedSeconds,
1232
+ $updated: now,
1233
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1234
+ $now: now
1235
+ });
1236
+ return this.requireGoal(goalId);
1237
+ }
1238
+ updateGoalPlanNode(goalId, key, patch, opts = {}) {
1239
+ const now = (opts.now ?? new Date).toISOString();
1240
+ this.db.query(`UPDATE goal_plan_nodes
1241
+ SET status=COALESCE($status, status),
1242
+ tokens_used=COALESCE($tokensUsed, tokens_used),
1243
+ time_used_seconds=COALESCE($timeUsedSeconds, time_used_seconds),
1244
+ ready=COALESCE($ready, ready),
1245
+ updated_at=$updated
1246
+ WHERE goal_id=$goalId AND key=$key
1247
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1248
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1249
+ ))`).run({
1250
+ $goalId: goalId,
1251
+ $key: key,
1252
+ $status: patch.status ?? null,
1253
+ $tokensUsed: patch.tokensUsed ?? null,
1254
+ $timeUsedSeconds: patch.timeUsedSeconds ?? null,
1255
+ $ready: patch.ready === undefined ? null : patch.ready ? 1 : 0,
1256
+ $updated: now,
1257
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1258
+ $now: now
1259
+ });
1260
+ const node = this.listGoalPlanNodes(goalId).find((entry) => entry.key === key);
1261
+ if (!node)
1262
+ throw new Error(`goal node not found: ${goalId}/${key}`);
1263
+ return node;
1264
+ }
1265
+ recordGoalEvent(input, opts = {}) {
1266
+ const goal = this.requireGoal(input.goalId);
1267
+ const now = nowIso();
1268
+ this.db.exec("BEGIN IMMEDIATE");
1269
+ try {
1270
+ this.assertDaemonLeaseFence(opts, now);
1271
+ const previous = this.db.query("SELECT MAX(turn) AS turn FROM goal_runs WHERE goal_id = ?").get(goal.goalId);
1272
+ const turn = input.turn ?? (previous?.turn ?? 0) + 1;
1273
+ const id = genId();
1274
+ this.db.query(`INSERT INTO goal_runs (id, goal_id, plan_id, loop_id, loop_run_id, workflow_id, workflow_run_id, workflow_step_id,
1275
+ turn, phase, status, node_key, tokens_used, evidence_json, raw_response_json, created_at, updated_at)
1276
+ VALUES ($id, $goalId, $planId, $loopId, $loopRunId, $workflowId, $workflowRunId, $workflowStepId,
1277
+ $turn, $phase, $status, $nodeKey, $tokensUsed, $evidence, $rawResponse, $created, $updated)`).run({
1278
+ $id: id,
1279
+ $goalId: goal.goalId,
1280
+ $planId: goal.planId,
1281
+ $loopId: goal.loopId ?? null,
1282
+ $loopRunId: goal.loopRunId ?? null,
1283
+ $workflowId: goal.workflowId ?? null,
1284
+ $workflowRunId: goal.workflowRunId ?? null,
1285
+ $workflowStepId: goal.workflowStepId ?? null,
1286
+ $turn: turn,
1287
+ $phase: input.phase,
1288
+ $status: input.status,
1289
+ $nodeKey: input.nodeKey ?? null,
1290
+ $tokensUsed: input.tokensUsed ?? 0,
1291
+ $evidence: input.evidence ? JSON.stringify(input.evidence) : null,
1292
+ $rawResponse: input.rawResponse === undefined ? null : JSON.stringify(input.rawResponse),
1293
+ $created: now,
1294
+ $updated: now
1295
+ });
1296
+ if (input.tokensUsed && input.tokensUsed > 0) {
1297
+ this.db.query("UPDATE goals SET tokens_used=tokens_used + ?, updated_at=? WHERE id=?").run(input.tokensUsed, now, goal.goalId);
1298
+ }
1299
+ this.db.exec("COMMIT");
1300
+ const event = this.db.query("SELECT * FROM goal_runs WHERE id = ?").get(id);
1301
+ if (!event)
1302
+ throw new Error(`goal run not found after record: ${id}`);
1303
+ return rowToGoalRun(event);
1304
+ } catch (error) {
1305
+ try {
1306
+ this.db.exec("ROLLBACK");
1307
+ } catch {}
1308
+ throw error;
1309
+ }
1310
+ }
1311
+ listGoalRuns(opts = {}) {
1312
+ const limit = opts.limit ?? 200;
1313
+ let rows;
1314
+ if (opts.goalId) {
1315
+ rows = this.db.query("SELECT * FROM goal_runs WHERE goal_id = ? ORDER BY created_at ASC LIMIT ?").all(opts.goalId, limit);
1316
+ } else if (opts.runId) {
1317
+ rows = this.db.query(`SELECT * FROM goal_runs
1318
+ WHERE id = ? OR loop_run_id = ? OR workflow_run_id = ?
1319
+ ORDER BY created_at ASC LIMIT ?`).all(opts.runId, opts.runId, opts.runId, limit);
1320
+ } else {
1321
+ rows = this.db.query("SELECT * FROM goal_runs ORDER BY created_at DESC LIMIT ?").all(limit);
1322
+ }
1323
+ return rows.map(rowToGoalRun);
1324
+ }
786
1325
  createWorkflowRun(input) {
787
1326
  const now = nowIso();
788
1327
  if (input.idempotencyKey) {
@@ -1608,9 +2147,23 @@ function publicWorkflowStepRun(run, showOutput = false) {
1608
2147
  function publicWorkflowEvent(event) {
1609
2148
  return { ...event, payload: redactSensitivePayload(event.payload) };
1610
2149
  }
2150
+ function publicGoal(goal) {
2151
+ return {
2152
+ ...goal,
2153
+ objective: redact(goal.objective, 120)
2154
+ };
2155
+ }
2156
+ function publicGoalRun(run) {
2157
+ return {
2158
+ ...run,
2159
+ evidence: redactSensitivePayload(run.evidence),
2160
+ rawResponse: redactSensitivePayload(run.rawResponse)
2161
+ };
2162
+ }
1611
2163
 
1612
2164
  // src/lib/executor.ts
1613
2165
  import { spawn, spawnSync as spawnSync2 } from "child_process";
2166
+ import { randomBytes as randomBytes2 } from "crypto";
1614
2167
  import { once } from "events";
1615
2168
  import { resolveMachineCommand } from "@hasna/machines/consumer";
1616
2169
 
@@ -1906,6 +2459,12 @@ function metadataEnv(metadata) {
1906
2459
  env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1907
2460
  if (metadata.workflowStepId)
1908
2461
  env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
2462
+ if (metadata.goalId)
2463
+ env.LOOPS_GOAL_ID = metadata.goalId;
2464
+ if (metadata.goalObjective)
2465
+ env.LOOPS_GOAL_OBJECTIVE = metadata.goalObjective;
2466
+ if (metadata.goalNodeKey)
2467
+ env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
1909
2468
  return env;
1910
2469
  }
1911
2470
  function providerCommand(provider) {
@@ -2043,9 +2602,9 @@ function commandForShell(spec) {
2043
2602
  return [spec.command, ...spec.args.map(shellQuote)].join(" ");
2044
2603
  }
2045
2604
  function hereDoc(value) {
2046
- let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
2605
+ let delimiter2 = `__OPENLOOPS_STDIN_${randomBytes2(8).toString("hex").toUpperCase()}__`;
2047
2606
  while (value.split(/\r?\n/).includes(delimiter2)) {
2048
- delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
2607
+ delimiter2 = `__OPENLOOPS_STDIN_${randomBytes2(8).toString("hex").toUpperCase()}__`;
2049
2608
  }
2050
2609
  return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
2051
2610
  }
@@ -2369,6 +2928,346 @@ async function executeLoop(loop, run, opts = {}) {
2369
2928
  }, { ...opts, machine: opts.machine ?? loop.machine });
2370
2929
  }
2371
2930
 
2931
+ // src/lib/goal/runner.ts
2932
+ import { generateObject } from "ai";
2933
+ import { z } from "zod";
2934
+
2935
+ // src/lib/goal/model-factory.ts
2936
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
2937
+ var DEFAULT_GOAL_MODEL = "openai/gpt-4o-mini";
2938
+ function resolveGoalModel(opts = {}) {
2939
+ const env = opts.env ?? process.env;
2940
+ const apiKey = opts.apiKey ?? env.OPENROUTER_API_KEY;
2941
+ if (!apiKey) {
2942
+ throw new Error("OPENROUTER_API_KEY is required to run goals with the OpenRouter AI SDK provider");
2943
+ }
2944
+ const provider = createOpenRouter({
2945
+ apiKey,
2946
+ baseURL: opts.baseURL ?? env.LOOPS_GOAL_BASE_URL ?? env.OPENROUTER_BASE_URL
2947
+ });
2948
+ return provider.chat(opts.model ?? env.LOOPS_GOAL_MODEL ?? DEFAULT_GOAL_MODEL);
2949
+ }
2950
+
2951
+ // src/lib/goal/prompts.ts
2952
+ function planPrompt(spec) {
2953
+ const budget = spec.tokenBudget ? `Token budget: ${spec.tokenBudget}.` : "No explicit token budget.";
2954
+ return [
2955
+ "Create a flat DAG goal plan for this objective.",
2956
+ "Each node must have a stable short key, a concrete objective, optional dependsOn keys, and optional priority.",
2957
+ "Prefer the smallest plan that can prove the explicit requirements.",
2958
+ budget,
2959
+ `Objective: ${spec.objective}`
2960
+ ].join(`
2961
+ `);
2962
+ }
2963
+ function achievementPrompt(goal, nodes, evidence) {
2964
+ return [
2965
+ "Run an adversarial achievement audit.",
2966
+ "Completion is unproven until every explicit requirement is verified against evidence.",
2967
+ "Return achieved=false if evidence is missing, ambiguous, or only asserts completion.",
2968
+ "adversarialReview must be non-empty and must describe the attempted falsification.",
2969
+ `Goal: ${goal.objective}`,
2970
+ `Nodes: ${nodes.map((node) => `${node.key}=${node.status}`).join(", ")}`,
2971
+ `Evidence:
2972
+ ${evidence.join(`
2973
+ ---
2974
+ `) || "(none)"}`
2975
+ ].join(`
2976
+ `);
2977
+ }
2978
+
2979
+ // src/lib/goal/runner.ts
2980
+ var DEFAULT_MAX_TURNS = 10;
2981
+ var PlanNodeSchema = z.object({
2982
+ key: z.string().min(1).max(64).regex(/^[A-Za-z0-9_.-]+$/),
2983
+ objective: z.string().min(1),
2984
+ dependsOn: z.array(z.string().min(1)).optional().default([]),
2985
+ priority: z.number().int().optional().default(0),
2986
+ tokenBudget: z.number().int().positive().optional()
2987
+ });
2988
+ var PlanSchema = z.object({
2989
+ nodes: z.array(PlanNodeSchema).min(1)
2990
+ });
2991
+ var AchievementSchema = z.object({
2992
+ achieved: z.boolean(),
2993
+ status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).optional(),
2994
+ evidence: z.array(z.string()).optional().default([]),
2995
+ unmetRequirements: z.array(z.string()).optional().default([]),
2996
+ adversarialReview: z.string().min(1)
2997
+ });
2998
+ function normalizeGoalSpec2(spec) {
2999
+ const objective = spec.objective.trim();
3000
+ if (!objective)
3001
+ throw new Error("goal.objective must be a non-empty string");
3002
+ if (objective.length > GOAL_OBJECTIVE_MAX_CHARS) {
3003
+ throw new Error(`goal.objective must be ${GOAL_OBJECTIVE_MAX_CHARS} characters or fewer`);
3004
+ }
3005
+ return { ...spec, objective, autoExecute: spec.autoExecute ?? "readyOnly" };
3006
+ }
3007
+ function usageTotal(value) {
3008
+ const usage = value;
3009
+ const input = typeof usage.inputTokens === "number" ? usage.inputTokens : usage.inputTokens?.total ?? 0;
3010
+ const output = typeof usage.outputTokens === "number" ? usage.outputTokens : usage.outputTokens?.total ?? 0;
3011
+ return usage.totalTokens ?? input + output;
3012
+ }
3013
+ function resultFromGoal(goal, status, stdout, error, startedAt = goal.createdAt) {
3014
+ const finishedAt = nowIso();
3015
+ return {
3016
+ status,
3017
+ exitCode: status === "succeeded" ? 0 : 1,
3018
+ stdout,
3019
+ stderr: "",
3020
+ error,
3021
+ startedAt,
3022
+ finishedAt,
3023
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
3024
+ goalId: goal.goalId
3025
+ };
3026
+ }
3027
+ function budgetExhausted(goal) {
3028
+ return goal.tokenBudget !== undefined && goal.tokensUsed >= goal.tokenBudget;
3029
+ }
3030
+ function sameBlockerKey(values) {
3031
+ return values.map((value) => value.trim()).filter(Boolean).join(`
3032
+ `) || "goal completion remains unproven";
3033
+ }
3034
+ function metadataFor(goal, node, context) {
3035
+ return {
3036
+ loopId: context?.loopId,
3037
+ loopName: context?.loopName,
3038
+ runId: context?.loopRunId,
3039
+ scheduledFor: context?.scheduledFor,
3040
+ workflowId: context?.workflowId,
3041
+ workflowName: context?.workflowName,
3042
+ workflowRunId: context?.workflowRunId,
3043
+ workflowStepId: context?.workflowStepId,
3044
+ goalId: goal.goalId,
3045
+ goalObjective: goal.objective,
3046
+ goalNodeKey: node.key
3047
+ };
3048
+ }
3049
+ async function executeUnderlyingTarget(target, goal, node, opts) {
3050
+ const metadata = metadataFor(goal, node, opts.context);
3051
+ if (opts.executeNode)
3052
+ return opts.executeNode(node, metadata);
3053
+ if (!target)
3054
+ throw new Error("runGoal requires either target or executeNode");
3055
+ return executeTarget(target, metadata, {
3056
+ env: opts.env,
3057
+ daemonLeaseId: opts.daemonLeaseId,
3058
+ beforePersist: opts.beforePersist,
3059
+ signal: opts.signal
3060
+ });
3061
+ }
3062
+ async function planGoal(store, goal, spec, model, opts) {
3063
+ const existing = store.listGoalPlanNodes(goal.goalId);
3064
+ if (existing.length > 0)
3065
+ return existing;
3066
+ const planned = await generateObject({
3067
+ model,
3068
+ schema: PlanSchema,
3069
+ temperature: 0,
3070
+ prompt: planPrompt(spec),
3071
+ abortSignal: opts.signal
3072
+ });
3073
+ const tokens = usageTotal(planned.usage);
3074
+ const rawNodes = planned.object.nodes.map((node, index) => ({
3075
+ key: node.key,
3076
+ objective: node.objective,
3077
+ dependsOn: node.dependsOn ?? [],
3078
+ priority: node.priority ?? 0,
3079
+ tokenBudget: node.tokenBudget,
3080
+ sequence: index
3081
+ }));
3082
+ assertAcyclicNodes(rawNodes.map((node) => ({ key: node.key, dependsOn: node.dependsOn })));
3083
+ store.recordGoalEvent({
3084
+ goalId: goal.goalId,
3085
+ turn: 0,
3086
+ phase: "plan",
3087
+ status: "active",
3088
+ tokensUsed: tokens,
3089
+ evidence: { nodeCount: rawNodes.length },
3090
+ rawResponse: planned.object
3091
+ }, { daemonLeaseId: opts.daemonLeaseId });
3092
+ return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
3093
+ }
3094
+ function stdoutFor(goal, nodes, evidence, validation) {
3095
+ return JSON.stringify({
3096
+ goal,
3097
+ rollup: rollupSummary(nodes),
3098
+ nodes,
3099
+ evidence,
3100
+ validation
3101
+ }, null, 2);
3102
+ }
3103
+ async function runGoal(store, input, opts = {}) {
3104
+ const spec = normalizeGoalSpec2(input);
3105
+ const model = opts.model ?? resolveGoalModel({ model: spec.model, env: opts.env });
3106
+ const startedAt = nowIso();
3107
+ const existing = store.findGoalByContext({
3108
+ loopRunId: opts.context?.loopRunId,
3109
+ workflowRunId: opts.context?.workflowRunId,
3110
+ workflowStepId: opts.context?.workflowStepId,
3111
+ sourceType: opts.context?.loopRunId || opts.context?.workflowRunId ? undefined : "manual",
3112
+ sourceId: opts.context?.loopRunId || opts.context?.workflowRunId ? undefined : spec.objective
3113
+ });
3114
+ let goal = existing ?? store.createGoal({
3115
+ objective: spec.objective,
3116
+ tokenBudget: spec.tokenBudget,
3117
+ autoExecute: spec.autoExecute,
3118
+ maxTokens: spec.maxTokens ?? spec.tokenBudget,
3119
+ sourceType: opts.context?.loopRunId || opts.context?.workflowRunId ? undefined : "manual",
3120
+ sourceId: opts.context?.loopRunId || opts.context?.workflowRunId ? undefined : spec.objective,
3121
+ loopId: opts.context?.loopId,
3122
+ loopRunId: opts.context?.loopRunId,
3123
+ workflowId: opts.context?.workflowId,
3124
+ workflowRunId: opts.context?.workflowRunId,
3125
+ workflowStepId: opts.context?.workflowStepId
3126
+ }, { daemonLeaseId: opts.daemonLeaseId });
3127
+ let nodes = await planGoal(store, goal, spec, model, opts);
3128
+ goal = store.requireGoal(goal.goalId);
3129
+ const evidence = [];
3130
+ let validation;
3131
+ let lastBlocker = "";
3132
+ let repeatedBlockerCount = 0;
3133
+ if (budgetExhausted(goal)) {
3134
+ goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
3135
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
3136
+ }
3137
+ for (let turn = 1;turn <= (spec.maxTurns ?? DEFAULT_MAX_TURNS); turn++) {
3138
+ if (opts.signal?.aborted) {
3139
+ goal = store.updateGoalStatus(goal.goalId, "cancelled", { daemonLeaseId: opts.daemonLeaseId });
3140
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
3141
+ }
3142
+ goal = store.requireGoal(goal.goalId);
3143
+ nodes = store.listGoalPlanNodes(goal.goalId);
3144
+ if (budgetExhausted(goal)) {
3145
+ goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
3146
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
3147
+ }
3148
+ const readyKeys = readyNodeKeys({
3149
+ status: goal.status === "active" ? "active" : goal.status === "budgetLimited" ? "budgetLimited" : "blocked",
3150
+ nodes
3151
+ });
3152
+ if (readyKeys.length > 0) {
3153
+ for (const key of readyKeys) {
3154
+ const node = store.listGoalPlanNodes(goal.goalId).find((entry) => entry.key === key);
3155
+ if (!node || node.status !== "pending")
3156
+ continue;
3157
+ opts.beforePersist?.();
3158
+ store.updateGoalPlanNode(goal.goalId, node.key, { status: "active", ready: false }, { daemonLeaseId: opts.daemonLeaseId });
3159
+ const result = await executeUnderlyingTarget(opts.target, goal, node, opts);
3160
+ store.recordGoalEvent({
3161
+ goalId: goal.goalId,
3162
+ turn,
3163
+ phase: "execute",
3164
+ status: result.status === "succeeded" ? "complete" : "active",
3165
+ nodeKey: node.key,
3166
+ evidence: {
3167
+ status: result.status,
3168
+ exitCode: result.exitCode,
3169
+ stdout: result.stdout,
3170
+ stderr: result.stderr,
3171
+ error: result.error
3172
+ }
3173
+ }, { daemonLeaseId: opts.daemonLeaseId });
3174
+ if (result.status === "succeeded") {
3175
+ evidence.push(`node ${node.key} succeeded
3176
+ stdout:
3177
+ ${result.stdout}
3178
+ stderr:
3179
+ ${result.stderr}`);
3180
+ store.updateGoalPlanNode(goal.goalId, node.key, {
3181
+ status: "complete",
3182
+ timeUsedSeconds: Math.round(result.durationMs / 1000)
3183
+ }, { daemonLeaseId: opts.daemonLeaseId });
3184
+ continue;
3185
+ }
3186
+ const blocker2 = `node ${node.key} ${result.status}${result.error ? `: ${result.error}` : ""}`;
3187
+ if (blocker2 === lastBlocker)
3188
+ repeatedBlockerCount += 1;
3189
+ else {
3190
+ lastBlocker = blocker2;
3191
+ repeatedBlockerCount = 1;
3192
+ }
3193
+ store.updateGoalPlanNode(goal.goalId, node.key, { status: repeatedBlockerCount >= 3 ? "blocked" : "pending" }, {
3194
+ daemonLeaseId: opts.daemonLeaseId
3195
+ });
3196
+ if (repeatedBlockerCount >= 3) {
3197
+ goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
3198
+ return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence), blocker2, startedAt);
3199
+ }
3200
+ break;
3201
+ }
3202
+ continue;
3203
+ }
3204
+ if (nodes.every((node) => node.status === "complete")) {
3205
+ const judged = await generateObject({
3206
+ model,
3207
+ schema: AchievementSchema,
3208
+ temperature: 0,
3209
+ prompt: achievementPrompt(goal, nodes, evidence),
3210
+ abortSignal: opts.signal
3211
+ });
3212
+ const tokens = usageTotal(judged.usage);
3213
+ validation = judged.object;
3214
+ const achieved = judged.object.achieved && judged.object.adversarialReview.trim().length > 0;
3215
+ const unmet = achieved ? [] : judged.object.unmetRequirements.length > 0 ? judged.object.unmetRequirements : ["adversarial review did not prove completion"];
3216
+ store.recordGoalEvent({
3217
+ goalId: goal.goalId,
3218
+ turn,
3219
+ phase: "validate",
3220
+ status: achieved ? "complete" : "blocked",
3221
+ tokensUsed: tokens,
3222
+ evidence: {
3223
+ achieved,
3224
+ evidence: judged.object.evidence,
3225
+ unmetRequirements: unmet,
3226
+ adversarialReview: judged.object.adversarialReview
3227
+ },
3228
+ rawResponse: judged.object
3229
+ }, { daemonLeaseId: opts.daemonLeaseId });
3230
+ goal = store.requireGoal(goal.goalId);
3231
+ if (achieved) {
3232
+ goal = store.updateGoalStatus(goal.goalId, "complete", { daemonLeaseId: opts.daemonLeaseId });
3233
+ return resultFromGoal(goal, "succeeded", stdoutFor(goal, nodes, evidence, validation), undefined, startedAt);
3234
+ }
3235
+ const blocker2 = sameBlockerKey(unmet);
3236
+ if (blocker2 === lastBlocker)
3237
+ repeatedBlockerCount += 1;
3238
+ else {
3239
+ lastBlocker = blocker2;
3240
+ repeatedBlockerCount = 1;
3241
+ }
3242
+ if (repeatedBlockerCount >= 3) {
3243
+ goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
3244
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker2, startedAt);
3245
+ }
3246
+ continue;
3247
+ }
3248
+ const blocker = "no ready goal nodes and goal plan is incomplete";
3249
+ if (blocker === lastBlocker)
3250
+ repeatedBlockerCount += 1;
3251
+ else {
3252
+ lastBlocker = blocker;
3253
+ repeatedBlockerCount = 1;
3254
+ }
3255
+ store.recordGoalEvent({
3256
+ goalId: goal.goalId,
3257
+ turn,
3258
+ phase: "status",
3259
+ status: repeatedBlockerCount >= 3 ? "blocked" : "active",
3260
+ evidence: { blocker }
3261
+ }, { daemonLeaseId: opts.daemonLeaseId });
3262
+ if (repeatedBlockerCount >= 3) {
3263
+ goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
3264
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
3265
+ }
3266
+ }
3267
+ goal = store.updateGoalStatus(goal.goalId, "usageLimited", { daemonLeaseId: opts.daemonLeaseId });
3268
+ return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation), "goal max turns exhausted", startedAt);
3269
+ }
3270
+
2372
3271
  // src/lib/workflow-runner.ts
2373
3272
  function targetWithStepAccount(step) {
2374
3273
  const account = step.account ?? step.target.account;
@@ -2391,6 +3290,24 @@ function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, erro
2391
3290
  };
2392
3291
  }
2393
3292
  async function executeWorkflow(store, workflow, opts = {}) {
3293
+ if (workflow.goal) {
3294
+ const workflowWithoutGoal = { ...workflow, goal: undefined };
3295
+ return runGoal(store, workflow.goal, {
3296
+ ...opts,
3297
+ context: {
3298
+ loopId: opts.loop?.id,
3299
+ loopName: opts.loop?.name,
3300
+ loopRunId: opts.loopRun?.id,
3301
+ scheduledFor: opts.loopRun?.scheduledFor ?? opts.scheduledFor,
3302
+ workflowId: workflow.id,
3303
+ workflowName: workflow.name
3304
+ },
3305
+ executeNode: async (node) => executeWorkflow(store, workflowWithoutGoal, {
3306
+ ...opts,
3307
+ idempotencyKey: `${opts.idempotencyKey ?? workflow.id}:goal:${node.key}`
3308
+ })
3309
+ });
3310
+ }
2394
3311
  const run = store.createWorkflowRun({
2395
3312
  workflow,
2396
3313
  loop: opts.loop,
@@ -2466,16 +3383,34 @@ async function executeWorkflow(store, workflow, opts = {}) {
2466
3383
  }, opts.cancelPollMs ?? 500);
2467
3384
  cancelTimer.unref();
2468
3385
  try {
2469
- result = await executeTarget(targetWithStepAccount(step), metadata, {
2470
- ...opts,
2471
- machine: opts.machine ?? opts.loop?.machine,
2472
- signal: controller.signal,
2473
- onSpawn: (pid) => {
2474
- opts.beforePersist?.();
2475
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
2476
- opts.onSpawn?.(pid);
2477
- }
2478
- });
3386
+ if (step.goal) {
3387
+ result = await runGoal(store, step.goal, {
3388
+ ...opts,
3389
+ target: targetWithStepAccount(step),
3390
+ signal: controller.signal,
3391
+ context: {
3392
+ loopId: opts.loop?.id,
3393
+ loopName: opts.loop?.name,
3394
+ loopRunId: opts.loopRun?.id,
3395
+ scheduledFor: opts.loopRun?.scheduledFor ?? opts.scheduledFor,
3396
+ workflowId: workflow.id,
3397
+ workflowName: workflow.name,
3398
+ workflowRunId: run.id,
3399
+ workflowStepId: step.id
3400
+ }
3401
+ });
3402
+ } else {
3403
+ result = await executeTarget(targetWithStepAccount(step), metadata, {
3404
+ ...opts,
3405
+ machine: opts.machine ?? opts.loop?.machine,
3406
+ signal: controller.signal,
3407
+ onSpawn: (pid) => {
3408
+ opts.beforePersist?.();
3409
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
3410
+ opts.onSpawn?.(pid);
3411
+ }
3412
+ });
3413
+ }
2479
3414
  } catch (error) {
2480
3415
  const finishedAt2 = nowIso();
2481
3416
  result = {
@@ -2553,9 +3488,42 @@ function preflightWorkflow(workflow, opts = {}) {
2553
3488
  }, opts));
2554
3489
  }
2555
3490
  async function executeLoopTarget(store, loop, run, opts = {}) {
2556
- if (loop.target.type !== "workflow")
3491
+ if (loop.target.type !== "workflow") {
3492
+ if (loop.goal) {
3493
+ return runGoal(store, loop.goal, {
3494
+ ...opts,
3495
+ target: loop.target,
3496
+ context: {
3497
+ loopId: loop.id,
3498
+ loopName: loop.name,
3499
+ loopRunId: run.id,
3500
+ scheduledFor: run.scheduledFor
3501
+ }
3502
+ });
3503
+ }
2557
3504
  return executeLoop(loop, run, opts);
3505
+ }
2558
3506
  const workflow = store.requireWorkflow(loop.target.workflowId);
3507
+ if (loop.goal) {
3508
+ return runGoal(store, loop.goal, {
3509
+ ...opts,
3510
+ context: {
3511
+ loopId: loop.id,
3512
+ loopName: loop.name,
3513
+ loopRunId: run.id,
3514
+ scheduledFor: run.scheduledFor,
3515
+ workflowId: workflow.id,
3516
+ workflowName: workflow.name
3517
+ },
3518
+ executeNode: async (node) => executeWorkflow(store, workflow, {
3519
+ ...opts,
3520
+ loop,
3521
+ loopRun: run,
3522
+ scheduledFor: run.scheduledFor,
3523
+ idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}:goal:${node.key}`
3524
+ })
3525
+ });
3526
+ }
2559
3527
  const controller = loop.target.timeoutMs ? new AbortController : undefined;
2560
3528
  let workflowTimedOut = false;
2561
3529
  const externalAbort = () => controller?.abort();
@@ -3248,7 +4216,7 @@ function runDoctor(store) {
3248
4216
 
3249
4217
  // src/cli/index.ts
3250
4218
  var program = new Command;
3251
- program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.5");
4219
+ program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.6");
3252
4220
  program.option("-j, --json", "print JSON");
3253
4221
  function isJson() {
3254
4222
  return Boolean(program.opts().json);
@@ -3327,6 +4295,7 @@ function baseCreateInput(name, opts, target) {
3327
4295
  description: typeof opts.description === "string" ? opts.description : undefined,
3328
4296
  schedule,
3329
4297
  target,
4298
+ goal: goalFromOpts(opts),
3330
4299
  machine: typeof opts.machine === "string" ? resolveLoopMachine(opts.machine) : undefined,
3331
4300
  ...policy,
3332
4301
  expiresAt: typeof opts.expiresAt === "string" ? new Date(opts.expiresAt).toISOString() : undefined
@@ -3341,6 +4310,23 @@ function addAccountOptions(command) {
3341
4310
  function addMachineOptions(command) {
3342
4311
  return command.option("--machine <id>", "OpenMachines machine id to assign this loop to");
3343
4312
  }
4313
+ function addGoalOptions(command) {
4314
+ return command.option("--goal <objective>", "wrap this loop target in an AI-SDK goal objective").option("--goal-budget <tokens>", "maximum goal orchestration token budget").option("--goal-model <model>", "OpenRouter model id for goal planning and validation").option("--goal-max-turns <n>", "maximum goal orchestration turns");
4315
+ }
4316
+ function goalFromOpts(opts) {
4317
+ const hasGoalOption = opts.goal !== undefined || opts.goalBudget !== undefined || opts.goalModel !== undefined || opts.goalMaxTurns !== undefined;
4318
+ if (!hasGoalOption)
4319
+ return;
4320
+ if (typeof opts.goal !== "string")
4321
+ throw new Error("--goal is required when using goal options");
4322
+ return normalizeGoalSpec({
4323
+ objective: opts.goal,
4324
+ tokenBudget: positiveInteger(typeof opts.goalBudget === "string" ? opts.goalBudget : undefined, "--goal-budget"),
4325
+ model: typeof opts.goalModel === "string" ? opts.goalModel : undefined,
4326
+ maxTurns: positiveInteger(typeof opts.goalMaxTurns === "string" ? opts.goalMaxTurns : undefined, "--goal-max-turns"),
4327
+ autoExecute: "readyOnly"
4328
+ }, "goal");
4329
+ }
3344
4330
  function accountFromOpts(opts) {
3345
4331
  if (!opts.account && opts.accountTool)
3346
4332
  throw new Error("--account-tool requires --account");
@@ -3354,7 +4340,7 @@ function providerAuthProfileFromOpts(opts, provider) {
3354
4340
  return opts.authProfile;
3355
4341
  }
3356
4342
  var create = program.command("create").description("create loops");
3357
- addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell")))).action((name, opts) => {
4343
+ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell"))))).action((name, opts) => {
3358
4344
  const store = new Store;
3359
4345
  try {
3360
4346
  const target = {
@@ -3371,7 +4357,7 @@ addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <
3371
4357
  store.close();
3372
4358
  }
3373
4359
  });
3374
- addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe")))).action((name, opts) => {
4360
+ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))))).action((name, opts) => {
3375
4361
  const provider = opts.provider;
3376
4362
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
3377
4363
  throw new Error("unsupported provider");
@@ -3399,7 +4385,7 @@ addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <na
3399
4385
  store.close();
3400
4386
  }
3401
4387
  });
3402
- addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name"))).action((name, opts) => {
4388
+ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name")))).action((name, opts) => {
3403
4389
  const store = new Store;
3404
4390
  try {
3405
4391
  const workflow = store.requireWorkflow(opts.workflow);
@@ -3415,6 +4401,51 @@ addMachineOptions(addScheduleOptions(create.command("workflow <name>").descripti
3415
4401
  });
3416
4402
  var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
3417
4403
  var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
4404
+ var goal = program.command("goal").description("inspect goal runs");
4405
+ goal.command("show <idOrName>").description("show a goal or configured loop/workflow goal").action((idOrName) => {
4406
+ const store = new Store;
4407
+ try {
4408
+ const runtimeGoal = store.getGoal(idOrName) ?? store.findGoalByLoop(idOrName);
4409
+ if (runtimeGoal) {
4410
+ const value = {
4411
+ goal: publicGoal(runtimeGoal),
4412
+ nodes: store.listGoalPlanNodes(runtimeGoal.goalId),
4413
+ runs: store.listGoalRuns({ goalId: runtimeGoal.goalId }).map(publicGoalRun)
4414
+ };
4415
+ print(value, `${runtimeGoal.goalId} ${runtimeGoal.status} ${runtimeGoal.objective}`);
4416
+ return;
4417
+ }
4418
+ const loop = store.getLoop(idOrName) ?? store.findLoopByName(idOrName);
4419
+ if (loop?.goal) {
4420
+ print({ config: loop.goal, loop: publicLoop(loop) }, `configured goal for loop ${loop.name}: ${loop.goal.objective}`);
4421
+ return;
4422
+ }
4423
+ const workflow = store.getWorkflow(idOrName) ?? store.findWorkflowByName(idOrName);
4424
+ if (workflow?.goal) {
4425
+ print({ config: workflow.goal, workflow: publicWorkflow(workflow) }, `configured goal for workflow ${workflow.name}: ${workflow.goal.objective}`);
4426
+ return;
4427
+ }
4428
+ throw new Error(`goal not found: ${idOrName}`);
4429
+ } finally {
4430
+ store.close();
4431
+ }
4432
+ });
4433
+ goal.command("status <runId>").description("show goal status for a goal, goal event, loop run, or workflow run").action((runId) => {
4434
+ const store = new Store;
4435
+ try {
4436
+ const runtimeGoal = store.findGoalByRunId(runId);
4437
+ if (!runtimeGoal)
4438
+ throw new Error(`goal run not found: ${runId}`);
4439
+ const value = {
4440
+ goal: publicGoal(runtimeGoal),
4441
+ nodes: store.listGoalPlanNodes(runtimeGoal.goalId),
4442
+ runs: store.listGoalRuns({ goalId: runtimeGoal.goalId }).map(publicGoalRun)
4443
+ };
4444
+ print(value, `${runtimeGoal.goalId} ${runtimeGoal.status} tokens=${runtimeGoal.tokensUsed}`);
4445
+ } finally {
4446
+ store.close();
4447
+ }
4448
+ });
3418
4449
  machines.command("list").alias("ls").description("list known machines").action(() => {
3419
4450
  const values = listOpenMachines();
3420
4451
  if (isJson())
@@ -3438,6 +4469,7 @@ workflows.command("validate <file>").description("validate a workflow JSON file
3438
4469
  description: body.description,
3439
4470
  version: body.version ?? 1,
3440
4471
  status: "active",
4472
+ goal: body.goal,
3441
4473
  steps: body.steps,
3442
4474
  createdAt: now,
3443
4475
  updatedAt: now