@hasna/loops 0.3.56 → 0.3.58

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
@@ -461,6 +461,9 @@ function validateTarget(value, label, opts) {
461
461
  }
462
462
  if (value.model !== undefined)
463
463
  assertString(value.model, `${label}.model`);
464
+ if (value.provider === "opencode" && (typeof value.model !== "string" || value.model.trim() === "")) {
465
+ throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
466
+ }
464
467
  if (value.variant !== undefined)
465
468
  assertString(value.variant, `${label}.variant`);
466
469
  if (value.agent !== undefined)
@@ -1257,12 +1260,21 @@ class Store {
1257
1260
  if (!row)
1258
1261
  throw new Error("daemon lease lost");
1259
1262
  }
1263
+ assertNoNestedWorkflowGoal(target, goal) {
1264
+ if (!goal || target.type !== "workflow")
1265
+ return;
1266
+ const workflow = this.getWorkflow(target.workflowId);
1267
+ if (workflow?.goal) {
1268
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1269
+ }
1270
+ }
1260
1271
  createLoop(input, from = new Date) {
1261
1272
  const now = nowIso();
1262
1273
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1263
1274
  name: "loop-target-validation",
1264
1275
  steps: [{ id: "target", target: input.target }]
1265
1276
  }).steps[0].target;
1277
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1266
1278
  const loop = {
1267
1279
  id: genId(),
1268
1280
  name: input.name,
@@ -1434,6 +1446,9 @@ class Store {
1434
1446
  if (this.hasRunningRun(current.id))
1435
1447
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1436
1448
  const workflow = this.requireWorkflow(workflowId);
1449
+ if (current.goal && workflow.goal) {
1450
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1451
+ }
1437
1452
  const target = { ...current.target, workflowId: workflow.id };
1438
1453
  if (opts.workflowTimeoutMs !== undefined)
1439
1454
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1472,6 +1487,9 @@ class Store {
1472
1487
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1473
1488
  if (this.hasRunningRun(current.id))
1474
1489
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1490
+ if (current.goal && normalized.goal) {
1491
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1492
+ }
1475
1493
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1476
1494
  const workflow = {
1477
1495
  id: genId(),
@@ -1763,7 +1781,7 @@ class Store {
1763
1781
  if (!sourceDedupeKey)
1764
1782
  throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1765
1783
  const now = nowIso();
1766
- const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1784
+ const claimableStatuses = ["queued", "deferred"];
1767
1785
  const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1768
1786
  const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1769
1787
  const result = this.db.query(`UPDATE workflow_invocations
@@ -1844,28 +1862,35 @@ class Store {
1844
1862
  project_group=excluded.project_group,
1845
1863
  priority=excluded.priority,
1846
1864
  status=CASE
1847
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1865
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
1848
1866
  THEN workflow_work_items.status
1849
1867
  ELSE excluded.status
1850
1868
  END,
1851
1869
  workflow_id=CASE
1852
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1870
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
1853
1871
  ELSE NULL
1854
1872
  END,
1855
1873
  loop_id=CASE
1856
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1874
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
1857
1875
  ELSE NULL
1858
1876
  END,
1859
1877
  workflow_run_id=CASE
1860
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1878
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
1861
1879
  ELSE NULL
1862
1880
  END,
1863
1881
  lease_expires_at=CASE
1864
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1882
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
1865
1883
  ELSE NULL
1866
1884
  END,
1867
1885
  next_attempt_at=excluded.next_attempt_at,
1868
- last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1886
+ last_reason=CASE
1887
+ WHEN workflow_work_items.attempts > 0
1888
+ AND workflow_work_items.status IN ('queued', 'deferred')
1889
+ AND workflow_work_items.last_reason IS NOT NULL
1890
+ AND excluded.last_reason IS NOT NULL
1891
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
1892
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
1893
+ END,
1869
1894
  updated_at=excluded.updated_at`).run({
1870
1895
  $id: id,
1871
1896
  $routeKey: input.routeKey,
@@ -1918,11 +1943,39 @@ class Store {
1918
1943
  const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
1919
1944
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1920
1945
  }
1946
+ requeueWorkflowWorkItem(id, patch = {}) {
1947
+ const current = this.getWorkflowWorkItem(id);
1948
+ if (!current)
1949
+ throw new Error(`workflow work item not found: ${id}`);
1950
+ const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
1951
+ if (!requeueableStatuses.includes(current.status)) {
1952
+ throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
1953
+ }
1954
+ const now = nowIso();
1955
+ const reason = patch.reason?.trim() || `requeued from ${current.status}`;
1956
+ const placeholders = requeueableStatuses.map(() => "?").join(",");
1957
+ const res = this.db.query(`UPDATE workflow_work_items
1958
+ SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
1959
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
1960
+ WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
1961
+ const item = this.getWorkflowWorkItem(id);
1962
+ if (!item)
1963
+ throw new Error(`workflow work item not found after requeue: ${id}`);
1964
+ if (res.changes !== 1)
1965
+ throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
1966
+ return item;
1967
+ }
1921
1968
  admitWorkflowWorkItem(id, patch) {
1922
1969
  const now = nowIso();
1923
1970
  const res = this.db.query(`UPDATE workflow_work_items
1924
1971
  SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1925
- next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1972
+ next_attempt_at=NULL,
1973
+ lease_expires_at=NULL,
1974
+ last_reason=CASE
1975
+ WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
1976
+ ELSE COALESCE($reason, last_reason)
1977
+ END,
1978
+ updated_at=$updated
1926
1979
  WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1927
1980
  $id: id,
1928
1981
  $workflowId: patch.workflowId,
@@ -3105,7 +3158,7 @@ class Store {
3105
3158
 
3106
3159
  // src/cli/index.ts
3107
3160
  import { createHash as createHash3, randomUUID } from "crypto";
3108
- import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync4, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
3161
+ import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync5, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
3109
3162
  import { spawnSync as spawnSync5 } from "child_process";
3110
3163
  import { dirname as dirname4, join as join6, resolve as resolve3 } from "path";
3111
3164
  import { tmpdir as tmpdir2 } from "os";
@@ -3580,6 +3633,9 @@ function assertSupportedAgentOptions(target) {
3580
3633
  assertStringOption(target.agent, `${target.provider}.agent`);
3581
3634
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3582
3635
  assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3636
+ if (target.provider === "opencode" && (target.model === undefined || target.model.trim() === "")) {
3637
+ throw new Error("opencode.model is required; pass a provider/model id such as openrouter/google/gemini-2.5-flash");
3638
+ }
3583
3639
  if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3584
3640
  throw new Error(`${target.provider}.configIsolation must be safe or none`);
3585
3641
  }
@@ -4352,6 +4408,78 @@ function sameBlockerKey(values) {
4352
4408
  return values.map((value) => value.trim()).filter(Boolean).join(`
4353
4409
  `) || "goal completion remains unproven";
4354
4410
  }
4411
+ function planStatusForGoal(goal) {
4412
+ if (goal.status === "usageLimited")
4413
+ return "blocked";
4414
+ return goal.status;
4415
+ }
4416
+ function syncReadyFlags(store, goal, nodes, opts) {
4417
+ const withReady = updateReadyFlags(nodes, planStatusForGoal(goal));
4418
+ for (const node of withReady) {
4419
+ const current = nodes.find((entry) => entry.key === node.key);
4420
+ if (current && current.ready !== node.ready) {
4421
+ store.updateGoalPlanNode(goal.goalId, node.key, { ready: node.ready }, { daemonLeaseId: opts.daemonLeaseId });
4422
+ }
4423
+ }
4424
+ return withReady;
4425
+ }
4426
+ function summarizeLabels(values, limit = 5) {
4427
+ if (values.length <= limit)
4428
+ return values.join(", ");
4429
+ return `${values.slice(0, limit).join(", ")} and ${values.length - limit} more`;
4430
+ }
4431
+ function noReadyDiagnostic(goal, nodes) {
4432
+ const planStatus = planStatusForGoal(goal);
4433
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
4434
+ const pendingNodes = nodes.filter((node) => node.status === "pending");
4435
+ const incompleteNodes = nodes.filter((node) => node.status !== "complete");
4436
+ const pendingDetails = pendingNodes.map((node) => ({
4437
+ key: node.key,
4438
+ status: node.status,
4439
+ ready: node.ready,
4440
+ tokenBudget: node.tokenBudget,
4441
+ tokensUsed: node.tokensUsed,
4442
+ budgetExhausted: nodeBudgetExhausted(node),
4443
+ unmetDependencies: node.dependsOn.map((dependency) => ({ key: dependency, status: byKey.get(dependency)?.status ?? "missing" })).filter((dependency) => dependency.status !== "complete")
4444
+ }));
4445
+ const incompleteDetails = incompleteNodes.map((node) => ({
4446
+ key: node.key,
4447
+ status: node.status,
4448
+ ready: node.ready
4449
+ }));
4450
+ let owner = "goal-plan";
4451
+ let cause = "goal plan has no ready nodes";
4452
+ if (planStatus !== "active") {
4453
+ owner = "goal";
4454
+ cause = `goal status is ${goal.status}`;
4455
+ } else if (pendingNodes.length === 0) {
4456
+ owner = "goal-plan";
4457
+ cause = `goal plan has no pending runnable nodes; incomplete nodes: ${summarizeLabels(incompleteDetails.map((node) => `${node.key}:${node.status}`))}`;
4458
+ } else if (pendingDetails.every((node) => node.budgetExhausted)) {
4459
+ owner = "goal-plan-node";
4460
+ cause = `all pending nodes are budget-exhausted: ${summarizeLabels(pendingDetails.map((node) => node.key))}`;
4461
+ } else {
4462
+ const missingDependencies = pendingDetails.flatMap((node) => node.unmetDependencies.filter((dependency) => dependency.status === "missing").map((dependency) => `${node.key}->${dependency.key}`));
4463
+ if (missingDependencies.length > 0) {
4464
+ owner = "goal-plan";
4465
+ cause = `pending nodes reference missing dependencies: ${summarizeLabels(missingDependencies)}`;
4466
+ } else if (pendingDetails.every((node) => node.unmetDependencies.length > 0 || node.budgetExhausted)) {
4467
+ owner = "goal-plan-node";
4468
+ const waiting = pendingDetails.filter((node) => node.unmetDependencies.length > 0).map((node) => `${node.key} waits on ${node.unmetDependencies.map((dependency) => `${dependency.key}:${dependency.status}`).join(",")}`);
4469
+ const exhausted = pendingDetails.filter((node) => node.budgetExhausted).map((node) => `${node.key} budget exhausted`);
4470
+ cause = `pending nodes are blocked by prerequisites: ${summarizeLabels([...waiting, ...exhausted])}`;
4471
+ }
4472
+ }
4473
+ const blocker = `${cause}; owner=${owner}`;
4474
+ return {
4475
+ owner,
4476
+ blocker,
4477
+ planStatus,
4478
+ rollup: rollupSummary(nodes),
4479
+ pendingNodes: pendingDetails,
4480
+ incompleteNodes: incompleteDetails
4481
+ };
4482
+ }
4355
4483
  function metadataFor(goal, node, context) {
4356
4484
  return {
4357
4485
  loopId: context?.loopId,
@@ -4412,13 +4540,14 @@ async function planGoal(store, goal, spec, model, opts) {
4412
4540
  }, { daemonLeaseId: opts.daemonLeaseId });
4413
4541
  return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
4414
4542
  }
4415
- function stdoutFor(goal, nodes, evidence, validation) {
4543
+ function stdoutFor(goal, nodes, evidence, validation, diagnostics) {
4416
4544
  return JSON.stringify({
4417
4545
  goal,
4418
4546
  rollup: rollupSummary(nodes),
4419
4547
  nodes,
4420
4548
  evidence,
4421
- validation
4549
+ validation,
4550
+ diagnostics
4422
4551
  }, null, 2);
4423
4552
  }
4424
4553
  async function runGoal(store, input, opts = {}) {
@@ -4451,6 +4580,7 @@ async function runGoal(store, input, opts = {}) {
4451
4580
  let validation;
4452
4581
  let lastBlocker = "";
4453
4582
  let repeatedBlockerCount = 0;
4583
+ let lastDiagnostic;
4454
4584
  if (budgetExhausted(goal)) {
4455
4585
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4456
4586
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
@@ -4461,13 +4591,13 @@ async function runGoal(store, input, opts = {}) {
4461
4591
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
4462
4592
  }
4463
4593
  goal = store.requireGoal(goal.goalId);
4464
- nodes = store.listGoalPlanNodes(goal.goalId);
4594
+ nodes = syncReadyFlags(store, goal, store.listGoalPlanNodes(goal.goalId), opts);
4465
4595
  if (budgetExhausted(goal)) {
4466
4596
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4467
4597
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
4468
4598
  }
4469
4599
  const readyKeys = readyNodeKeys({
4470
- status: goal.status === "active" ? "active" : goal.status === "budgetLimited" ? "budgetLimited" : "blocked",
4600
+ status: planStatusForGoal(goal),
4471
4601
  nodes
4472
4602
  });
4473
4603
  if (readyKeys.length > 0) {
@@ -4566,7 +4696,9 @@ ${result.stderr}`);
4566
4696
  }
4567
4697
  continue;
4568
4698
  }
4569
- const blocker = "no ready goal nodes and goal plan is incomplete";
4699
+ const diagnostic = noReadyDiagnostic(goal, nodes);
4700
+ lastDiagnostic = diagnostic;
4701
+ const blocker = diagnostic.blocker;
4570
4702
  if (blocker === lastBlocker)
4571
4703
  repeatedBlockerCount += 1;
4572
4704
  else {
@@ -4578,15 +4710,16 @@ ${result.stderr}`);
4578
4710
  turn,
4579
4711
  phase: "status",
4580
4712
  status: repeatedBlockerCount >= 3 ? "blocked" : "active",
4581
- evidence: { blocker }
4713
+ evidence: diagnostic
4582
4714
  }, { daemonLeaseId: opts.daemonLeaseId });
4583
4715
  if (repeatedBlockerCount >= 3) {
4584
4716
  goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
4585
- return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
4717
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation, diagnostic), blocker, startedAt);
4586
4718
  }
4587
4719
  }
4588
4720
  goal = store.updateGoalStatus(goal.goalId, "usageLimited", { daemonLeaseId: opts.daemonLeaseId });
4589
- return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation), "goal max turns exhausted", startedAt);
4721
+ const exhaustedError = lastDiagnostic?.blocker ?? (lastBlocker ? `${lastBlocker}; max turns exhausted` : "goal max turns exhausted");
4722
+ return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation, lastDiagnostic), exhaustedError, startedAt);
4590
4723
  }
4591
4724
 
4592
4725
  // src/lib/workflow-runner.ts
@@ -4615,6 +4748,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4615
4748
  const workflowWithoutGoal = { ...workflow, goal: undefined };
4616
4749
  return runGoal(store, workflow.goal, {
4617
4750
  ...opts,
4751
+ model: opts.goalModel,
4618
4752
  context: {
4619
4753
  loopId: opts.loop?.id,
4620
4754
  loopName: opts.loop?.name,
@@ -4707,6 +4841,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4707
4841
  if (step.goal) {
4708
4842
  result = await runGoal(store, step.goal, {
4709
4843
  ...opts,
4844
+ model: opts.goalModel,
4710
4845
  target: targetWithStepAccount(step),
4711
4846
  signal: controller.signal,
4712
4847
  context: {
@@ -4848,6 +4983,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4848
4983
  if (loop.goal) {
4849
4984
  return runGoal(store, loop.goal, {
4850
4985
  ...opts,
4986
+ model: opts.goalModel,
4851
4987
  target: loop.target,
4852
4988
  context: {
4853
4989
  loopId: loop.id,
@@ -4869,8 +5005,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4869
5005
  }
4870
5006
  }
4871
5007
  if (loop.goal) {
5008
+ const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
4872
5009
  return runGoal(store, loop.goal, {
4873
5010
  ...opts,
5011
+ model: opts.goalModel,
4874
5012
  context: {
4875
5013
  loopId: loop.id,
4876
5014
  loopName: loop.name,
@@ -4879,7 +5017,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4879
5017
  workflowId: workflow.id,
4880
5018
  workflowName: workflow.name
4881
5019
  },
4882
- executeNode: async (node) => executeWorkflow(store, workflow, {
5020
+ executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
4883
5021
  ...opts,
4884
5022
  loop,
4885
5023
  loopRun: run,
@@ -5708,6 +5846,7 @@ function runDoctor(store) {
5708
5846
 
5709
5847
  // src/lib/health.ts
5710
5848
  import { createHash as createHash2 } from "crypto";
5849
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
5711
5850
  var EVIDENCE_CHARS = 2000;
5712
5851
  var FINGERPRINT_EVIDENCE_CHARS = 120;
5713
5852
  var CLASSIFICATIONS = [
@@ -5718,6 +5857,7 @@ var CLASSIFICATIONS = [
5718
5857
  "schema_response_format",
5719
5858
  "node_init",
5720
5859
  "preflight",
5860
+ "route_functional",
5721
5861
  "timeout",
5722
5862
  "sigsegv",
5723
5863
  "skipped_previous_active",
@@ -5738,6 +5878,25 @@ function searchableText(run) {
5738
5878
  return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
5739
5879
  `).toLowerCase();
5740
5880
  }
5881
+ function isRecord(value) {
5882
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
5883
+ }
5884
+ function stringValue(value) {
5885
+ return typeof value === "string" && value.trim() ? value : undefined;
5886
+ }
5887
+ function objectField(value, key) {
5888
+ if (!value)
5889
+ return;
5890
+ const field = value[key];
5891
+ return isRecord(field) ? field : undefined;
5892
+ }
5893
+ function tagsFromValue(value) {
5894
+ if (Array.isArray(value))
5895
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
5896
+ if (typeof value === "string")
5897
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
5898
+ return [];
5899
+ }
5741
5900
  function stableFingerprint(parts) {
5742
5901
  return createHash2("sha256").update(parts.join(`
5743
5902
  `)).digest("hex").slice(0, 16);
@@ -5751,6 +5910,13 @@ function stableFailureFingerprint(run, classification) {
5751
5910
  (run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
5752
5911
  ]);
5753
5912
  }
5913
+ function stableRouteFunctionalFingerprint(loop, reason) {
5914
+ return stableFingerprint([
5915
+ loop.id,
5916
+ "route_functional",
5917
+ reason.replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
5918
+ ]);
5919
+ }
5754
5920
  function healthRun(run) {
5755
5921
  return {
5756
5922
  ...run,
@@ -5795,6 +5961,148 @@ function classifyRunFailure(run) {
5795
5961
  }
5796
5962
  };
5797
5963
  }
5964
+ var ROUTE_FUNCTIONAL_DISALLOWED_TAGS = new Set([
5965
+ "no-auto",
5966
+ "manual",
5967
+ "manual-required",
5968
+ "approval-required",
5969
+ "blocked",
5970
+ "completed",
5971
+ "done",
5972
+ "cancelled",
5973
+ "canceled",
5974
+ "failed",
5975
+ "archived"
5976
+ ]);
5977
+ var ROUTE_FUNCTIONAL_DISALLOWED_STATUSES = new Set([
5978
+ "blocked",
5979
+ "completed",
5980
+ "done",
5981
+ "cancelled",
5982
+ "canceled",
5983
+ "failed",
5984
+ "archived"
5985
+ ]);
5986
+ function parseJsonObject(raw) {
5987
+ if (!raw?.trim())
5988
+ return;
5989
+ try {
5990
+ const parsed = JSON.parse(raw);
5991
+ return isRecord(parsed) ? parsed : undefined;
5992
+ } catch {
5993
+ return;
5994
+ }
5995
+ }
5996
+ function routeEvidenceReport(run) {
5997
+ const stdoutReport = parseJsonObject(run.stdout);
5998
+ const evidencePath = stringValue(stdoutReport?.evidencePath);
5999
+ if (evidencePath && existsSync4(evidencePath)) {
6000
+ try {
6001
+ return parseJsonObject(readFileSync3(evidencePath, "utf8")) ?? stdoutReport;
6002
+ } catch {
6003
+ return stdoutReport;
6004
+ }
6005
+ }
6006
+ return stdoutReport;
6007
+ }
6008
+ function commandName(command) {
6009
+ return command.split(/[\\/]/).at(-1) ?? command;
6010
+ }
6011
+ function argsContainSequence(args, sequence) {
6012
+ for (let index = 0;index <= args.length - sequence.length; index += 1) {
6013
+ if (sequence.every((part, offset) => args[index + offset] === part))
6014
+ return true;
6015
+ }
6016
+ return false;
6017
+ }
6018
+ function isRouteDrainLoop(loop) {
6019
+ if (loop.target.type !== "command")
6020
+ return false;
6021
+ if (commandName(loop.target.command) !== "loops")
6022
+ return false;
6023
+ const args = loop.target.args ?? [];
6024
+ return argsContainSequence(args, ["events", "drain", "todos-task"]) || argsContainSequence(args, ["routes", "drain", "todos-task"]) || argsContainSequence(args, ["route", "drain", "todos-task"]);
6025
+ }
6026
+ function routeResultTaskState(result) {
6027
+ const event = objectField(result, "event");
6028
+ const data = objectField(event, "data");
6029
+ const task = objectField(data, "task");
6030
+ const payload = objectField(data, "payload");
6031
+ const payloadTask = objectField(payload, "task");
6032
+ const metadata = objectField(data, "metadata");
6033
+ const records = [data, task, payload, payloadTask, metadata].filter(isRecord);
6034
+ const tags = new Set;
6035
+ for (const record of records) {
6036
+ for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags)) {
6037
+ tags.add(tag.toLowerCase());
6038
+ }
6039
+ }
6040
+ const status = records.map((record) => stringValue(record.status ?? record.task_status ?? record.taskStatus)?.toLowerCase()).find(Boolean);
6041
+ return {
6042
+ taskId: stringValue(event?.subject) ?? stringValue(data?.id) ?? stringValue(task?.id) ?? stringValue(payloadTask?.id),
6043
+ tags: [...tags],
6044
+ status
6045
+ };
6046
+ }
6047
+ function detectRouteFunctionalFailure(store, loop, run) {
6048
+ if (run.status !== "succeeded")
6049
+ return;
6050
+ if (!isRouteDrainLoop(loop))
6051
+ return;
6052
+ const report = routeEvidenceReport(run);
6053
+ const rawResults = Array.isArray(report?.results) ? report.results.filter(isRecord) : [];
6054
+ for (const result of rawResults) {
6055
+ const kind = stringValue(result.kind);
6056
+ const task = routeResultTaskState(result);
6057
+ const disallowedTag = task.tags.find((tag) => ROUTE_FUNCTIONAL_DISALLOWED_TAGS.has(tag));
6058
+ if (kind && kind !== "skipped" && disallowedTag) {
6059
+ const reason2 = `route drain ${kind} task ${task.taskId ?? "unknown"} with disallowed tag ${disallowedTag}`;
6060
+ return {
6061
+ classification: "route_functional",
6062
+ fingerprint: stableRouteFunctionalFingerprint(loop, reason2),
6063
+ evidence: { error: reason2, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6064
+ };
6065
+ }
6066
+ if (kind && kind !== "skipped" && task.status && ROUTE_FUNCTIONAL_DISALLOWED_STATUSES.has(task.status)) {
6067
+ const reason2 = `route drain ${kind} task ${task.taskId ?? "unknown"} with non-routable status ${task.status}`;
6068
+ return {
6069
+ classification: "route_functional",
6070
+ fingerprint: stableRouteFunctionalFingerprint(loop, reason2),
6071
+ evidence: { error: reason2, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6072
+ };
6073
+ }
6074
+ const reason = stringValue(result.reason);
6075
+ if (kind === "skipped" && reason === "task metadata requires manual or approval-gated handling") {
6076
+ const message = `route drain skipped task ${task.taskId ?? "unknown"} with ambiguous manual-gate reason`;
6077
+ return {
6078
+ classification: "route_functional",
6079
+ fingerprint: stableRouteFunctionalFingerprint(loop, message),
6080
+ evidence: { error: message, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6081
+ };
6082
+ }
6083
+ const sourceTaskUpdate = objectField(result, "sourceTaskUpdate");
6084
+ if (kind === "skipped" && sourceTaskUpdate && sourceTaskUpdate.ok === false) {
6085
+ const updateError = stringValue(sourceTaskUpdate.error);
6086
+ const message = `route drain skipped task ${task.taskId ?? "unknown"} but failed to update source task${updateError ? `: ${updateError}` : ""}`;
6087
+ return {
6088
+ classification: "route_functional",
6089
+ fingerprint: stableRouteFunctionalFingerprint(loop, message),
6090
+ evidence: { error: message, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6091
+ };
6092
+ }
6093
+ const childLoopId = stringValue(objectField(result, "loop")?.id) ?? stringValue(result.loopId);
6094
+ const childRun = childLoopId ? store.listRuns({ loopId: childLoopId, limit: 1 })[0] : undefined;
6095
+ if (childRun && !["succeeded", "running"].includes(childRun.status)) {
6096
+ const message = `route drain ${kind ?? "handled"} task ${task.taskId ?? "unknown"} but child loop ${childLoopId} latest run is ${childRun.status}`;
6097
+ return {
6098
+ classification: "route_functional",
6099
+ fingerprint: stableRouteFunctionalFingerprint(loop, message),
6100
+ evidence: { error: message, stdout: redactedEvidence(run.stdout), stderr: redactedEvidence(childRun.stderr), exitCode: childRun.exitCode }
6101
+ };
6102
+ }
6103
+ }
6104
+ return;
6105
+ }
5798
6106
  function targetRoute(loop) {
5799
6107
  if (loop.target.type === "agent") {
5800
6108
  return {
@@ -5882,6 +6190,18 @@ function expectationForLoop(store, loop) {
5882
6190
  route
5883
6191
  };
5884
6192
  }
6193
+ const routeFailure = detectRouteFunctionalFailure(store, loop, latestRun);
6194
+ if (routeFailure) {
6195
+ return {
6196
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
6197
+ ok: false,
6198
+ check: { id: "route-functional-health", status: "fail", message: routeFailure.evidence.error ?? "route functional blocker detected" },
6199
+ latestRun: healthRun(latestRun),
6200
+ failure: routeFailure,
6201
+ route,
6202
+ recommendedTask: recommendedTask(loop, latestRun, routeFailure, route)
6203
+ };
6204
+ }
5885
6205
  if (latestRun.status === "succeeded") {
5886
6206
  return {
5887
6207
  loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
@@ -5940,6 +6260,8 @@ var PROVIDER_TOKENS = new Set([
5940
6260
  "agent"
5941
6261
  ]);
5942
6262
  var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
6263
+ var CADENCE_SUFFIX_TOKENS = new Set(["hourly", "daily", "weekly", "monthly"]);
6264
+ var CADENCE_SUFFIX_PATTERN = /^(?:every-?)?\d+(?:s|m|h|d|w)$/;
5943
6265
  function slugify(value) {
5944
6266
  return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
5945
6267
  }
@@ -5992,6 +6314,16 @@ function taskSlug(loop, scope) {
5992
6314
  if (deduped[deduped.length - 1] !== token)
5993
6315
  deduped.push(token);
5994
6316
  }
6317
+ while (deduped.length) {
6318
+ const last = deduped[deduped.length - 1];
6319
+ if (CADENCE_SUFFIX_TOKENS.has(last) || CADENCE_SUFFIX_PATTERN.test(last)) {
6320
+ deduped.pop();
6321
+ if (deduped[deduped.length - 1] === "every")
6322
+ deduped.pop();
6323
+ continue;
6324
+ }
6325
+ break;
6326
+ }
5995
6327
  return deduped.join("-") || "loop";
5996
6328
  }
5997
6329
  function canonicalName(loop) {
@@ -6165,14 +6497,15 @@ function buildScriptInventoryReport(store, opts = {}) {
6165
6497
  // package.json
6166
6498
  var package_default = {
6167
6499
  name: "@hasna/loops",
6168
- version: "0.3.56",
6500
+ version: "0.3.58",
6169
6501
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
6170
6502
  type: "module",
6171
6503
  main: "dist/index.js",
6172
6504
  types: "dist/index.d.ts",
6173
6505
  bin: {
6174
6506
  loops: "dist/cli/index.js",
6175
- "loops-daemon": "dist/daemon/index.js"
6507
+ "loops-daemon": "dist/daemon/index.js",
6508
+ "loops-mcp": "dist/mcp/index.js"
6176
6509
  },
6177
6510
  exports: {
6178
6511
  ".": {
@@ -6183,6 +6516,10 @@ var package_default = {
6183
6516
  types: "./dist/sdk/index.d.ts",
6184
6517
  import: "./dist/sdk/index.js"
6185
6518
  },
6519
+ "./mcp": {
6520
+ types: "./dist/mcp/index.d.ts",
6521
+ import: "./dist/mcp/index.js"
6522
+ },
6186
6523
  "./storage": {
6187
6524
  types: "./dist/lib/store.d.ts",
6188
6525
  import: "./dist/lib/store.js"
@@ -6195,7 +6532,7 @@ var package_default = {
6195
6532
  "LICENSE"
6196
6533
  ],
6197
6534
  scripts: {
6198
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
6535
+ build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
6199
6536
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
6200
6537
  typecheck: "tsc --noEmit",
6201
6538
  test: "bun test",
@@ -6231,6 +6568,7 @@ var package_default = {
6231
6568
  dependencies: {
6232
6569
  "@hasna/events": "^0.1.9",
6233
6570
  "@hasna/machines": "0.0.49",
6571
+ "@modelcontextprotocol/sdk": "^1.29.0",
6234
6572
  "@openrouter/ai-sdk-provider": "2.9.1",
6235
6573
  ai: "6.0.204",
6236
6574
  commander: "^13.1.0",
@@ -6255,7 +6593,7 @@ function packageVersion() {
6255
6593
 
6256
6594
  // src/lib/templates.ts
6257
6595
  import { execFileSync } from "child_process";
6258
- import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
6596
+ import { existsSync as existsSync5, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
6259
6597
  import { homedir as homedir3 } from "os";
6260
6598
  import { basename as basename3, isAbsolute as isAbsolute2, join as join5, relative, resolve as resolve2 } from "path";
6261
6599
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
@@ -6296,7 +6634,8 @@ var TEMPLATE_SUMMARIES = [
6296
6634
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6297
6635
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6298
6636
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." },
6299
- { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6637
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6638
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6300
6639
  ]
6301
6640
  },
6302
6641
  {
@@ -6327,7 +6666,8 @@ var TEMPLATE_SUMMARIES = [
6327
6666
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6328
6667
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6329
6668
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." },
6330
- { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6669
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6670
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6331
6671
  ]
6332
6672
  },
6333
6673
  {
@@ -6355,7 +6695,8 @@ var TEMPLATE_SUMMARIES = [
6355
6695
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6356
6696
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6357
6697
  { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
6358
- { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6698
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6699
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6359
6700
  ]
6360
6701
  },
6361
6702
  {
@@ -6374,8 +6715,10 @@ var TEMPLATE_SUMMARIES = [
6374
6715
  { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
6375
6716
  { name: "provider", default: "codewith", description: "Agent provider." },
6376
6717
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6718
+ { name: "prHandoff", default: "false", description: "Add a bounded network-enabled PR handoff task step after the worker." },
6377
6719
  { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6378
- { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
6720
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6721
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6379
6722
  ]
6380
6723
  },
6381
6724
  {
@@ -6390,7 +6733,8 @@ var TEMPLATE_SUMMARIES = [
6390
6733
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6391
6734
  { name: "provider", default: "codewith", description: "Agent provider." },
6392
6735
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6393
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6736
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6737
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6394
6738
  ]
6395
6739
  },
6396
6740
  {
@@ -6404,7 +6748,8 @@ var TEMPLATE_SUMMARIES = [
6404
6748
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6405
6749
  { name: "provider", default: "codewith", description: "Agent provider." },
6406
6750
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6407
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6751
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6752
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6408
6753
  ]
6409
6754
  },
6410
6755
  {
@@ -6418,7 +6763,8 @@ var TEMPLATE_SUMMARIES = [
6418
6763
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6419
6764
  { name: "provider", default: "codewith", description: "Agent provider." },
6420
6765
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6421
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6766
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6767
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6422
6768
  ]
6423
6769
  },
6424
6770
  {
@@ -6432,7 +6778,8 @@ var TEMPLATE_SUMMARIES = [
6432
6778
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6433
6779
  { name: "provider", default: "codewith", description: "Agent provider." },
6434
6780
  { name: "sandbox", default: "read-only", description: "Provider sandbox mode." },
6435
- { name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." }
6781
+ { name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." },
6782
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6436
6783
  ]
6437
6784
  },
6438
6785
  {
@@ -6447,7 +6794,8 @@ var TEMPLATE_SUMMARIES = [
6447
6794
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6448
6795
  { name: "provider", default: "codewith", description: "Agent provider." },
6449
6796
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6450
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6797
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6798
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6451
6799
  ]
6452
6800
  },
6453
6801
  {
@@ -6481,9 +6829,25 @@ function taskLabel(input) {
6481
6829
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
6482
6830
  }
6483
6831
  var UNLIMITED_AGENT_TIMEOUT_MS = null;
6832
+ var DEFAULT_VERIFIER_IDLE_TIMEOUT_MS = 15 * 60000;
6484
6833
  function agentTimeoutMs(input) {
6485
6834
  return input.timeoutMs === undefined ? UNLIMITED_AGENT_TIMEOUT_MS : input.timeoutMs;
6486
6835
  }
6836
+ function verifierIdleTimeoutMs(input) {
6837
+ if (input.verifierIdleTimeoutMs === undefined)
6838
+ return DEFAULT_VERIFIER_IDLE_TIMEOUT_MS;
6839
+ return input.verifierIdleTimeoutMs > 0 ? input.verifierIdleTimeoutMs : undefined;
6840
+ }
6841
+ function verifierRuntimeGuidance(input) {
6842
+ const idleTimeout = verifierIdleTimeoutMs(input);
6843
+ return [
6844
+ "Verifier runtime contract:",
6845
+ idleTimeout ? `- OpenLoops will mark this verifier timed_out after ${idleTimeout}ms without stdout/stderr. Emit a concise heartbeat/progress line before long checks.` : "- The verifier idle watchdog is disabled for this workflow; still emit concise progress before long checks.",
6846
+ "- Keep final evidence compact: summarize changed files, validation commands/results, findings, and the task decision instead of pasting bulky logs.",
6847
+ "- If validation cannot finish, record a clear blocked/failed task comment with the last completed check and the next concrete action."
6848
+ ].join(`
6849
+ `);
6850
+ }
6487
6851
  function parseTemplateTimeoutMs(raw) {
6488
6852
  if (raw === undefined || raw.trim() === "")
6489
6853
  return;
@@ -6496,6 +6860,18 @@ function parseTemplateTimeoutMs(raw) {
6496
6860
  }
6497
6861
  return value;
6498
6862
  }
6863
+ function parseTemplateIdleTimeoutMs(raw) {
6864
+ if (raw === undefined || raw.trim() === "")
6865
+ return;
6866
+ const normalized = raw.trim().toLowerCase();
6867
+ if (["unlimited", "none", "null", "never", "off", "false"].includes(normalized))
6868
+ return 0;
6869
+ const value = Number(raw);
6870
+ if (!Number.isInteger(value) || value <= 0) {
6871
+ throw new Error("verifierIdleTimeoutMs must be a positive integer number of milliseconds, or none/off");
6872
+ }
6873
+ return value;
6874
+ }
6499
6875
  function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
6500
6876
  if (raw === undefined || raw.trim() === "")
6501
6877
  return fallbackMs;
@@ -6556,6 +6932,179 @@ function stableHex(seed) {
6556
6932
  function shellQuote2(value) {
6557
6933
  return `'${value.replace(/'/g, `'\\''`)}'`;
6558
6934
  }
6935
+ function prHandoffArtifactPath(plan, taskId) {
6936
+ return join5(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
6937
+ }
6938
+ function prHandoffCommand(input, plan, todosProjectPath) {
6939
+ const artifactPath = prHandoffArtifactPath(plan, input.taskId);
6940
+ return [
6941
+ "set -euo pipefail",
6942
+ `export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote2(artifactPath)}`,
6943
+ `export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote2(input.taskId)}`,
6944
+ `export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote2(todosProjectPath)}`,
6945
+ `export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote2(plan.cwd)}`,
6946
+ `export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(plan.path ?? plan.cwd)}`,
6947
+ `export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(plan.branch ?? "")}`,
6948
+ 'if [ ! -s "$OPENLOOPS_PR_HANDOFF_ARTIFACT" ]; then',
6949
+ ` printf 'no PR handoff artifact at %s\\n' "$OPENLOOPS_PR_HANDOFF_ARTIFACT"`,
6950
+ " exit 0",
6951
+ "fi",
6952
+ "bun - <<'BUN'",
6953
+ "const { readFileSync, realpathSync } = await import('node:fs');",
6954
+ "const { spawnSync } = await import('node:child_process');",
6955
+ "const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
6956
+ "const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
6957
+ "const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
6958
+ "const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
6959
+ "const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
6960
+ "const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
6961
+ "const todosBin = process.env.OPENLOOPS_PR_HANDOFF_TODOS_BIN || 'todos';",
6962
+ "const gitBin = process.env.OPENLOOPS_PR_HANDOFF_GIT_BIN || 'git';",
6963
+ "const ghBin = process.env.OPENLOOPS_PR_HANDOFF_GH_BIN || 'gh';",
6964
+ "const raw = readFileSync(artifactPath, 'utf8');",
6965
+ "const artifact = JSON.parse(raw);",
6966
+ "const stringField = (...keys) => {",
6967
+ " for (const key of keys) {",
6968
+ " const value = artifact[key];",
6969
+ " if (typeof value === 'string' && value.trim()) return value.trim();",
6970
+ " }",
6971
+ " return undefined;",
6972
+ "};",
6973
+ "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
6974
+ "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
6975
+ "const todos = (...args) => run(todosBin, todosArgs(...args));",
6976
+ "const comment = (text) => {",
6977
+ " const result = todos('comment', taskId, text);",
6978
+ " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
6979
+ "};",
6980
+ "const repoPath = stringField('worktreePath', 'localRepoPath', 'repoPath', 'cwd') || fallbackWorktree;",
6981
+ "const artifactTaskId = stringField('taskId', 'sourceTaskId', 'originalTaskId');",
6982
+ "const branch = stringField('branch', 'headBranch');",
6983
+ "const base = stringField('base', 'baseBranch') || 'main';",
6984
+ "const remote = stringField('remote') || 'origin';",
6985
+ "let commit = stringField('commit', 'commitSha', 'sha');",
6986
+ "const repo = stringField('githubRepo', 'repoSlug', 'repository');",
6987
+ "const prUrl = stringField('prUrl', 'pullRequestUrl');",
6988
+ "const title = stringField('title', 'prTitle') || `PR handoff for ${taskId}`;",
6989
+ "const body = stringField('body', 'prBody') || [",
6990
+ " `OpenLoops PR handoff for task ${taskId}.`,",
6991
+ " `Commit: ${commit || 'unknown'}`,",
6992
+ " `Branch: ${branch || 'unknown'}`,",
6993
+ " artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
6994
+ " artifact.error ? `Worker network error: ${artifact.error}` : undefined,",
6995
+ "].filter(Boolean).join('\\n\\n');",
6996
+ "const fingerprint = stringField('fingerprint') || `openloops:pr-handoff:${taskId}:${branch || 'missing-branch'}:${commit || 'missing-commit'}`;",
6997
+ "const repoTagSource = (repo || stringField('repo', 'remoteUrl') || repoPath).split(/[/:]/).filter(Boolean).at(-1) || 'unknown';",
6998
+ "const repoTag = `repo:${repoTagSource.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'}`;",
6999
+ "const metadata = {",
7000
+ " route_enabled: true,",
7001
+ " source: 'openloops.pr-handoff',",
7002
+ " original_task_id: taskId,",
7003
+ " repo: repo || stringField('repo', 'remoteUrl') || '',",
7004
+ " branch: branch || '',",
7005
+ " base,",
7006
+ " commit: commit || '',",
7007
+ " artifact_path: artifactPath,",
7008
+ " fingerprint,",
7009
+ " automation: { allowed: true, mode: 'auto' },",
7010
+ " no_tmux_dispatch: true,",
7011
+ "};",
7012
+ "const upsertTask = (why) => {",
7013
+ " const description = [",
7014
+ " `OpenLoops could not complete network PR handoff for original task ${taskId}.`,",
7015
+ " `Reason: ${why}`,",
7016
+ " `Fingerprint: ${fingerprint}`,",
7017
+ " `Repository: ${repo || stringField('repo', 'remoteUrl') || 'unknown'}`,",
7018
+ " `Worktree: ${repoPath}`,",
7019
+ " `Branch: ${branch || 'unknown'}`,",
7020
+ " `Base: ${base}`,",
7021
+ " `Commit: ${commit || 'unknown'}`,",
7022
+ " `Artifact: ${artifactPath}`,",
7023
+ " artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
7024
+ " artifact.error ? `Worker error: ${artifact.error}` : undefined,",
7025
+ " 'Do not rerun implementation work. Push the recorded commit/branch, open or update the PR, then comment the original task with the PR URL and validation evidence.',",
7026
+ " ].filter(Boolean).join('\\n\\n');",
7027
+ " const result = todos(",
7028
+ " 'task',",
7029
+ " 'upsert',",
7030
+ " '--fingerprint', fingerprint,",
7031
+ " '--title', `PR handoff for ${taskId}`,",
7032
+ " '-d', description,",
7033
+ " '-p', 'high',",
7034
+ " '-t', ['auto:route', 'pr-handoff', 'github', 'network', repoTag].join(','),",
7035
+ " '--metadata-json', JSON.stringify(metadata),",
7036
+ " '--working-dir', repoPath,",
7037
+ " );",
7038
+ " if (result.status !== 0) throw new Error(`todos task upsert failed: ${result.stderr || result.stdout || result.status}`);",
7039
+ " comment(`openloops:pr-handoff=pending task=${taskId} artifact=${artifactPath} fingerprint=${fingerprint} reason=${why}`);",
7040
+ " console.log(`queued PR handoff task fingerprint=${fingerprint}`);",
7041
+ "};",
7042
+ "const queueNetworkHandoff = (why) => { upsertTask(why); process.exit(0); };",
7043
+ "const invalidArtifact = (why) => {",
7044
+ " comment(`openloops:pr-handoff=invalid task=${taskId} artifact=${artifactPath} reason=${why}`);",
7045
+ " console.error(`invalid PR handoff artifact: ${why}`);",
7046
+ " process.exit(0);",
7047
+ "};",
7048
+ "const canonicalPath = (path) => {",
7049
+ " try { return realpathSync(path); } catch { return path; }",
7050
+ "};",
7051
+ "if (artifactTaskId && artifactTaskId !== taskId) invalidArtifact(`artifact task id ${artifactTaskId} does not match expected ${taskId}`);",
7052
+ "if (!branch || !commit) invalidArtifact('artifact missing branch or commit');",
7053
+ "const topLevel = run(gitBin, ['-C', repoPath, 'rev-parse', '--show-toplevel']);",
7054
+ "if (topLevel.status !== 0) invalidArtifact(`artifact repoPath is not a git worktree: ${String(topLevel.stderr || topLevel.stdout || topLevel.status).slice(0, 300)}`);",
7055
+ "const actualRoot = canonicalPath(String(topLevel.stdout || '').trim());",
7056
+ "const wantedRoot = canonicalPath(expectedRoot);",
7057
+ "if (actualRoot !== wantedRoot) invalidArtifact(`artifact repo root mismatch: expected ${wantedRoot}, got ${actualRoot}`);",
7058
+ "const currentBranch = run(gitBin, ['-C', repoPath, 'branch', '--show-current']);",
7059
+ "const actualBranch = String(currentBranch.stdout || '').trim();",
7060
+ "if (currentBranch.status !== 0 || !actualBranch) invalidArtifact(`could not resolve current branch for artifact repo: ${String(currentBranch.stderr || currentBranch.stdout || currentBranch.status).slice(0, 300)}`);",
7061
+ "if (expectedBranch && branch !== expectedBranch) invalidArtifact(`artifact branch ${branch} does not match expected ${expectedBranch}`);",
7062
+ "if (branch !== actualBranch) invalidArtifact(`artifact branch ${branch} does not match current worktree branch ${actualBranch}`);",
7063
+ "const resolvedCommit = run(gitBin, ['-C', repoPath, 'rev-parse', '--verify', `${commit}^{commit}`]);",
7064
+ "if (resolvedCommit.status !== 0) invalidArtifact(`artifact commit is not present in repo: ${String(resolvedCommit.stderr || resolvedCommit.stdout || resolvedCommit.status).slice(0, 300)}`);",
7065
+ "commit = String(resolvedCommit.stdout || commit).trim();",
7066
+ "const reachable = run(gitBin, ['-C', repoPath, 'merge-base', '--is-ancestor', commit, 'HEAD']);",
7067
+ "if (reachable.status !== 0) invalidArtifact(`artifact commit ${commit} is not reachable from HEAD`);",
7068
+ "if (prUrl) {",
7069
+ ` const viewed = run(ghBin, ['pr', 'view', prUrl, '--json', 'url,headRefName', '--jq', '.url + "\\\\n" + .headRefName']);`,
7070
+ " if (viewed.status !== 0) queueNetworkHandoff(`could not verify existing PR URL: ${String(viewed.stderr || viewed.stdout || viewed.status).slice(0, 300)}`);",
7071
+ " const [verifiedUrl, verifiedHead] = String(viewed.stdout || '').trim().split(/\\r?\\n/);",
7072
+ " if (!verifiedUrl || !/^https?:\\/\\//.test(verifiedUrl)) invalidArtifact('verified PR URL was missing or invalid');",
7073
+ " if (verifiedHead && verifiedHead !== branch) invalidArtifact(`verified PR head ${verifiedHead} does not match artifact branch ${branch}`);",
7074
+ " comment(`openloops:pr-handoff=done task=${taskId} pr=${verifiedUrl} commit=${commit} branch=${branch}`);",
7075
+ " console.log(`PR handoff already complete: ${verifiedUrl}`);",
7076
+ " process.exit(0);",
7077
+ "}",
7078
+ "const push = run(gitBin, ['-C', repoPath, 'push', remote, `${commit}:refs/heads/${branch}`]);",
7079
+ "if (push.status !== 0) {",
7080
+ " upsertTask(`git push failed: ${String(push.stderr || push.stdout || push.status).slice(0, 300)}`);",
7081
+ " process.exit(0);",
7082
+ "}",
7083
+ "const ghRepoArgs = repo ? ['--repo', repo] : [];",
7084
+ "const existing = run(ghBin, ['pr', 'list', ...ghRepoArgs, '--head', branch, '--state', 'all', '--json', 'url', '--jq', '.[0].url']);",
7085
+ "let finalPrUrl = existing.status === 0 ? String(existing.stdout || '').trim() : '';",
7086
+ "if (!finalPrUrl) {",
7087
+ " const created = run(ghBin, ['pr', 'create', ...ghRepoArgs, '--base', base, '--head', branch, '--title', title, '--body', body], { cwd: repoPath });",
7088
+ " if (created.status !== 0) {",
7089
+ " upsertTask(`gh pr create failed: ${String(created.stderr || created.stdout || created.status).slice(0, 300)}`);",
7090
+ " process.exit(0);",
7091
+ " }",
7092
+ " finalPrUrl = String(created.stdout || '').trim().split(/\\r?\\n/).find((line) => /^https?:\\/\\//.test(line)) || String(created.stdout || '').trim();",
7093
+ "}",
7094
+ "comment(`openloops:pr-handoff=done task=${taskId} pr=${finalPrUrl} commit=${commit} branch=${branch}`);",
7095
+ "console.log(`PR handoff complete: ${finalPrUrl}`);",
7096
+ "BUN"
7097
+ ].join(`
7098
+ `);
7099
+ }
7100
+ function sourceTaskGateCommand(todosProjectPath, taskId) {
7101
+ return [
7102
+ "set -euo pipefail",
7103
+ `todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
7104
+ `printf "source task %s resolved in todos project %s\\n" ${shellQuote2(taskId)} ${shellQuote2(todosProjectPath)}`
7105
+ ].join(`
7106
+ `);
7107
+ }
6559
7108
  function normalizeWorktreeMode(mode) {
6560
7109
  const value = mode ?? "auto";
6561
7110
  if (!["auto", "required", "off", "main"].includes(value)) {
@@ -6571,7 +7120,7 @@ function defaultWorktreeRoot(root) {
6571
7120
  return join5(homedir3(), ".hasna", "loops", "worktrees");
6572
7121
  }
6573
7122
  function gitRootFor(path) {
6574
- if (!existsSync4(path))
7123
+ if (!existsSync5(path))
6575
7124
  return;
6576
7125
  try {
6577
7126
  return execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
@@ -6583,7 +7132,7 @@ function gitRootFor(path) {
6583
7132
  }
6584
7133
  }
6585
7134
  function gitCommonDirFor(path) {
6586
- if (!existsSync4(path))
7135
+ if (!existsSync5(path))
6587
7136
  return;
6588
7137
  try {
6589
7138
  const raw = execFileSync("git", ["-C", path, "rev-parse", "--git-common-dir"], {
@@ -6795,7 +7344,8 @@ function agentTarget(input, prompt, role, seed, plan) {
6795
7344
  ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
6796
7345
  },
6797
7346
  account: accountForRole(input, role, seed),
6798
- timeoutMs: agentTimeoutMs(input)
7347
+ timeoutMs: agentTimeoutMs(input),
7348
+ idleTimeoutMs: role === "verifier" ? verifierIdleTimeoutMs(input) : undefined
6799
7349
  };
6800
7350
  }
6801
7351
  function workflowStepsWithWorktree(plan, steps) {
@@ -6975,7 +7525,7 @@ function customTemplateSummary(definition, sourcePath) {
6975
7525
  function readCustomTemplateFile(file) {
6976
7526
  let parsed;
6977
7527
  try {
6978
- parsed = JSON.parse(readFileSync3(file, "utf8"));
7528
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
6979
7529
  } catch (error) {
6980
7530
  const message = error instanceof Error ? error.message : String(error);
6981
7531
  throw new Error(`failed to read custom template ${file}: ${message}`);
@@ -7001,7 +7551,7 @@ function assertNoTemplateCollisions(entries) {
7001
7551
  }
7002
7552
  function loadCustomLoopTemplatesRaw() {
7003
7553
  const dir = customLoopTemplatesDir();
7004
- if (!existsSync4(dir))
7554
+ if (!existsSync5(dir))
7005
7555
  return [];
7006
7556
  return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
7007
7557
  const file = join5(dir, entry.name);
@@ -7125,7 +7675,7 @@ function importCustomLoopTemplate(file, opts = {}) {
7125
7675
  const entry = readCustomTemplateFile(source);
7126
7676
  const dir = ensureCustomLoopTemplatesDir();
7127
7677
  const destination = join5(dir, `${entry.definition.id}.json`);
7128
- const replaced = existsSync4(destination);
7678
+ const replaced = existsSync5(destination);
7129
7679
  const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== resolve2(destination));
7130
7680
  assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
7131
7681
  if (replaced) {
@@ -7221,6 +7771,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
7221
7771
  `- Record verification: todos --project ${todosProjectPath} comment ${input.taskId} "<verification evidence or blocker>"`,
7222
7772
  `- If valid and complete: todos --project ${todosProjectPath} done ${input.taskId}`,
7223
7773
  "Use fresh context. Inspect the task, repository state, commits, tests, and worker evidence. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
7774
+ verifierRuntimeGuidance(input),
7224
7775
  "If the work is valid, record verification evidence in todos and mark/leave the task in the correct completed state according to the todos CLI. If it is not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
7225
7776
  "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
7226
7777
  "Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks.",
@@ -7233,10 +7784,24 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
7233
7784
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
7234
7785
  version: 1,
7235
7786
  steps: workflowStepsWithWorktree(plan, [
7787
+ {
7788
+ id: "source-task-gate",
7789
+ name: "Source Task Gate",
7790
+ description: "Fail before worker execution when the source todos task is not resolvable.",
7791
+ target: {
7792
+ type: "command",
7793
+ command: "bash",
7794
+ args: ["-lc", sourceTaskGateCommand(todosProjectPath, input.taskId)],
7795
+ cwd: plan.cwd,
7796
+ timeoutMs: 60000
7797
+ },
7798
+ timeoutMs: 60000
7799
+ },
7236
7800
  {
7237
7801
  id: "worker",
7238
7802
  name: "Worker",
7239
7803
  description: "Implement the todos task and record evidence.",
7804
+ dependsOn: ["source-task-gate"],
7240
7805
  target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
7241
7806
  timeoutMs: agentTimeoutMs(input)
7242
7807
  },
@@ -7277,6 +7842,14 @@ function renderTaskLifecycleWorkflow(input) {
7277
7842
  reason: plan.reason
7278
7843
  }
7279
7844
  };
7845
+ const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
7846
+ const prHandoffGuidance = input.prHandoff ? [
7847
+ "PR handoff mode is enabled for this lifecycle.",
7848
+ `If implementation and validation pass but git push or gh PR creation fails because DNS, network, or sandbox policy blocks GitHub access, write a JSON artifact to: ${handoffArtifactPath}`,
7849
+ "The artifact must include taskId, worktreePath or repoPath, branch, base, commit, remote, validation, and error. Include githubRepo, title, and body when known.",
7850
+ "After writing the artifact, comment the source task with the artifact path and exit without marking the task done. The bounded PR handoff step will push/open the PR or queue a network-enabled handoff task without rerunning implementation."
7851
+ ].join(`
7852
+ `) : "";
7280
7853
  const shared = [
7281
7854
  worktreePrompt(plan),
7282
7855
  `Todos project path: ${todosProjectPath}`,
@@ -7286,7 +7859,8 @@ function renderTaskLifecycleWorkflow(input) {
7286
7859
  "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
7287
7860
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
7288
7861
  "",
7289
- `Task context JSON: ${compactJson(taskContext)}`
7862
+ `Task context JSON: ${compactJson(taskContext)}`,
7863
+ prHandoffGuidance
7290
7864
  ].join(`
7291
7865
  `);
7292
7866
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
@@ -7330,7 +7904,7 @@ function renderTaskLifecycleWorkflow(input) {
7330
7904
  "const latestMarker = markers.at(-1)?.state;",
7331
7905
  "const blockers = [];",
7332
7906
  "if (blockedStatuses.has(status)) blockers.push(`task status is ${status}`);",
7333
- "for (const tag of ['no-auto', 'manual', 'manual-required', 'approval-required']) {",
7907
+ "for (const tag of ['no-auto', 'manual', 'manual-required', 'approval-required', 'blocked', 'completed', 'done', 'cancelled', 'canceled', 'failed', 'archived']) {",
7334
7908
  " if (tags.has(tag)) blockers.push(`task has disallowed tag ${tag}`);",
7335
7909
  "}",
7336
7910
  "for (const [key, source] of records.entries()) {",
@@ -7359,7 +7933,7 @@ function renderTaskLifecycleWorkflow(input) {
7359
7933
  "Include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same comment.",
7360
7934
  `If the task should not proceed automatically, run: todos --project ${todosProjectPath} update ${input.taskId} --status blocked`,
7361
7935
  `Then add a task comment whose first line is exactly: ${gateMarker("triage", "blocked")}`,
7362
- "The deterministic triage gate will stop later steps unless the latest triage marker is the exact go marker and the task has no blocked/no-auto/manual/approval-required state."
7936
+ "The deterministic triage gate will stop later steps unless the latest triage marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state."
7363
7937
  ].join(`
7364
7938
  `);
7365
7939
  const plannerPrompt = [
@@ -7372,7 +7946,7 @@ function renderTaskLifecycleWorkflow(input) {
7372
7946
  "In that same comment, include a concise implementation plan: files/areas to inspect, validation commands, risk checks, expected commit/PR behavior, and any cross-repo tasks that should be created separately.",
7373
7947
  `Do not implement repo changes in this step. If the task is too broad or unsafe for automation, run: todos --project ${todosProjectPath} update ${input.taskId} --status blocked`,
7374
7948
  `Then add a task comment whose first line is exactly: ${gateMarker("planner", "blocked")}`,
7375
- "Create smaller deduped tasks and record evidence. The deterministic planner gate will stop the worker unless the latest planner marker is the exact go marker and the task has no blocked/no-auto/manual/approval-required state."
7949
+ "Create smaller deduped tasks and record evidence. The deterministic planner gate will stop the worker unless the latest planner marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state."
7376
7950
  ].join(`
7377
7951
  `);
7378
7952
  const workerPrompt = [
@@ -7382,8 +7956,9 @@ function renderTaskLifecycleWorkflow(input) {
7382
7956
  shared,
7383
7957
  `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${input.taskId}`,
7384
7958
  "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
7959
+ input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
7385
7960
  "Do not mark the task complete in the worker step; the verifier step owns completion after independent validation."
7386
- ].join(`
7961
+ ].filter(Boolean).join(`
7387
7962
  `);
7388
7963
  const verifierPrompt = [
7389
7964
  `/goal Verify todos task ${input.taskId} after the full lifecycle worker step.`,
@@ -7393,75 +7968,108 @@ function renderTaskLifecycleWorkflow(input) {
7393
7968
  `- Record verification: todos --project ${todosProjectPath} comment ${input.taskId} "<verification evidence or blocker>"`,
7394
7969
  `- If valid and complete: todos --project ${todosProjectPath} done ${input.taskId}`,
7395
7970
  "Use fresh context. Inspect triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
7971
+ verifierRuntimeGuidance(input),
7972
+ input.prHandoff ? `If ${handoffArtifactPath} exists and there is no PR URL evidence, verify that the PR handoff step queued or completed a bounded handoff; leave the original task open or blocked until PR evidence is recorded.` : undefined,
7396
7973
  "If the work is valid, record verification evidence in todos and mark/leave the task completed according to the todos CLI. If not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
7397
7974
  "Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks."
7398
- ].join(`
7975
+ ].filter(Boolean).join(`
7399
7976
  `);
7400
- return {
7401
- name: `task-lifecycle-${input.taskId.slice(0, 8)}-triage-plan-worker-verifier`,
7402
- description: `Full task lifecycle workflow for ${taskLabel(input)}`,
7403
- version: 1,
7404
- steps: workflowStepsWithWorktree(plan, [
7405
- {
7406
- id: "triage",
7407
- name: "Triage",
7408
- description: "Check task eligibility, duplicates, dependencies, and automation gates.",
7409
- target: agentTarget(input, triagePrompt, "triage", input.taskId, plan),
7410
- timeoutMs: agentTimeoutMs(input)
7977
+ const steps = [
7978
+ {
7979
+ id: "source-task-gate",
7980
+ name: "Source Task Gate",
7981
+ description: "Fail before lifecycle agents execute when the source todos task is not resolvable.",
7982
+ target: {
7983
+ type: "command",
7984
+ command: "bash",
7985
+ args: ["-lc", sourceTaskGateCommand(todosProjectPath, input.taskId)],
7986
+ cwd: plan.cwd,
7987
+ timeoutMs: 60000
7411
7988
  },
7412
- {
7413
- id: "triage-gate",
7414
- name: "Triage Gate",
7415
- description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
7416
- dependsOn: ["triage"],
7417
- target: {
7418
- type: "command",
7419
- command: "bash",
7420
- args: ["-lc", gateCommand("triage")],
7421
- cwd: plan.cwd,
7422
- timeoutMs: 2 * 60000
7423
- },
7989
+ timeoutMs: 60000
7990
+ },
7991
+ {
7992
+ id: "triage",
7993
+ name: "Triage",
7994
+ description: "Check task eligibility, duplicates, dependencies, and automation gates.",
7995
+ dependsOn: ["source-task-gate"],
7996
+ target: agentTarget(input, triagePrompt, "triage", input.taskId, plan),
7997
+ timeoutMs: agentTimeoutMs(input)
7998
+ },
7999
+ {
8000
+ id: "triage-gate",
8001
+ name: "Triage Gate",
8002
+ description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
8003
+ dependsOn: ["triage"],
8004
+ target: {
8005
+ type: "command",
8006
+ command: "bash",
8007
+ args: ["-lc", gateCommand("triage")],
8008
+ cwd: plan.cwd,
7424
8009
  timeoutMs: 2 * 60000
7425
8010
  },
7426
- {
7427
- id: "planner",
7428
- name: "Planner",
7429
- description: "Create a concise implementation plan and split unsafe scope before work starts.",
7430
- dependsOn: ["triage-gate"],
7431
- target: agentTarget(input, plannerPrompt, "planner", input.taskId, plan),
7432
- timeoutMs: agentTimeoutMs(input)
7433
- },
7434
- {
7435
- id: "planner-gate",
7436
- name: "Planner Gate",
7437
- description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
7438
- dependsOn: ["planner"],
7439
- target: {
7440
- type: "command",
7441
- command: "bash",
7442
- args: ["-lc", gateCommand("planner")],
7443
- cwd: plan.cwd,
7444
- timeoutMs: 2 * 60000
7445
- },
8011
+ timeoutMs: 2 * 60000
8012
+ },
8013
+ {
8014
+ id: "planner",
8015
+ name: "Planner",
8016
+ description: "Create a concise implementation plan and split unsafe scope before work starts.",
8017
+ dependsOn: ["triage-gate"],
8018
+ target: agentTarget(input, plannerPrompt, "planner", input.taskId, plan),
8019
+ timeoutMs: agentTimeoutMs(input)
8020
+ },
8021
+ {
8022
+ id: "planner-gate",
8023
+ name: "Planner Gate",
8024
+ description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
8025
+ dependsOn: ["planner"],
8026
+ target: {
8027
+ type: "command",
8028
+ command: "bash",
8029
+ args: ["-lc", gateCommand("planner")],
8030
+ cwd: plan.cwd,
7446
8031
  timeoutMs: 2 * 60000
7447
8032
  },
7448
- {
7449
- id: "worker",
7450
- name: "Worker",
7451
- description: "Implement the todos task according to triage and planner evidence.",
7452
- dependsOn: ["planner-gate"],
7453
- target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
7454
- timeoutMs: agentTimeoutMs(input)
8033
+ timeoutMs: 2 * 60000
8034
+ },
8035
+ {
8036
+ id: "worker",
8037
+ name: "Worker",
8038
+ description: "Implement the todos task according to triage and planner evidence.",
8039
+ dependsOn: ["planner-gate"],
8040
+ target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
8041
+ timeoutMs: agentTimeoutMs(input)
8042
+ }
8043
+ ];
8044
+ if (input.prHandoff) {
8045
+ steps.push({
8046
+ id: "pr-handoff",
8047
+ name: "PR Handoff",
8048
+ description: "Push/open a PR from a worker handoff artifact or queue a bounded network handoff task.",
8049
+ dependsOn: ["worker"],
8050
+ target: {
8051
+ type: "command",
8052
+ command: "bash",
8053
+ args: ["-lc", prHandoffCommand(input, plan, todosProjectPath)],
8054
+ cwd: plan.cwd,
8055
+ timeoutMs: 2 * 60000
7455
8056
  },
7456
- {
7457
- id: "verifier",
7458
- name: "Verifier",
7459
- description: "Adversarially verify worker output and update todos.",
7460
- dependsOn: ["worker"],
7461
- target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
7462
- timeoutMs: agentTimeoutMs(input)
7463
- }
7464
- ])
8057
+ timeoutMs: 2 * 60000
8058
+ });
8059
+ }
8060
+ steps.push({
8061
+ id: "verifier",
8062
+ name: "Verifier",
8063
+ description: "Adversarially verify worker output and update todos.",
8064
+ dependsOn: [input.prHandoff ? "pr-handoff" : "worker"],
8065
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
8066
+ timeoutMs: agentTimeoutMs(input)
8067
+ });
8068
+ return {
8069
+ name: `task-lifecycle-${input.taskId.slice(0, 8)}-triage-plan-worker-verifier`,
8070
+ description: `Full task lifecycle workflow for ${taskLabel(input)}`,
8071
+ version: 1,
8072
+ steps: workflowStepsWithWorktree(plan, steps)
7465
8073
  };
7466
8074
  }
7467
8075
  function renderEventWorkerVerifierWorkflow(input) {
@@ -7513,6 +8121,7 @@ function renderEventWorkerVerifierWorkflow(input) {
7513
8121
  "You are the verifier agent for an event-triggered OpenLoops workflow.",
7514
8122
  worktreePrompt(plan),
7515
8123
  "Use fresh context. Inspect the event, repository/project state, worker evidence, tests, and any created tasks or notes. Act as an adversarial reviewer focused on correctness, regressions, security, missing evidence, and incomplete requirements.",
8124
+ verifierRuntimeGuidance(input),
7516
8125
  "If the work is valid, record verification evidence in the relevant local system. If it is not valid, add precise follow-up tasks/comments and leave the event handling state open or blocked with clear evidence.",
7517
8126
  "",
7518
8127
  `Event context JSON: ${compactJson(eventContext)}`,
@@ -7567,6 +8176,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
7567
8176
  "You are the verifier step for a bounded OpenLoops agent workflow.",
7568
8177
  worktreePrompt(plan),
7569
8178
  "Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
8179
+ verifierRuntimeGuidance(input),
7570
8180
  "If valid, record verification evidence. If invalid, create precise follow-up tasks or comments and leave the original work open. Do not make broad unrelated changes."
7571
8181
  ].join(`
7572
8182
  `);
@@ -7617,7 +8227,8 @@ function renderLifecycleBoundedTemplate(id, values) {
7617
8227
  worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
7618
8228
  worktreeRoot: values.worktreeRoot,
7619
8229
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7620
- timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
8230
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8231
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
7621
8232
  };
7622
8233
  if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
7623
8234
  const taskId = values.taskId ?? "";
@@ -7651,10 +8262,12 @@ function renderLifecycleBoundedTemplate(id, values) {
7651
8262
  permissionMode: values.permissionMode,
7652
8263
  sandbox: values.sandbox,
7653
8264
  manualBreakGlass: booleanVar(values.manualBreakGlass),
8265
+ prHandoff: booleanVar(values.prHandoff),
7654
8266
  worktreeMode: values.worktreeMode ?? "required",
7655
8267
  worktreeRoot: values.worktreeRoot,
7656
8268
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7657
8269
  timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8270
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout),
7658
8271
  eventId: values.eventId,
7659
8272
  eventType: values.eventType
7660
8273
  });
@@ -7779,6 +8392,7 @@ function renderBuiltinLoopTemplate(id, values) {
7779
8392
  worktreeRoot: values.worktreeRoot,
7780
8393
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7781
8394
  timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8395
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout),
7782
8396
  eventId: values.eventId,
7783
8397
  eventType: values.eventType
7784
8398
  });
@@ -7811,7 +8425,8 @@ function renderBuiltinLoopTemplate(id, values) {
7811
8425
  worktreeMode: values.worktreeMode,
7812
8426
  worktreeRoot: values.worktreeRoot,
7813
8427
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7814
- timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
8428
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8429
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
7815
8430
  });
7816
8431
  }
7817
8432
  if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
@@ -7839,7 +8454,8 @@ function renderBuiltinLoopTemplate(id, values) {
7839
8454
  worktreeMode: values.worktreeMode,
7840
8455
  worktreeRoot: values.worktreeRoot,
7841
8456
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7842
- timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
8457
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8458
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
7843
8459
  });
7844
8460
  }
7845
8461
  throw new Error(`unknown template: ${id}`);
@@ -7946,7 +8562,7 @@ function normalizeWorkflowForStorage(body, context) {
7946
8562
  function workflowBodyFromFile(file, fallbackName, context) {
7947
8563
  try {
7948
8564
  const resolved = resolve3(file);
7949
- return workflowBodyFromJson(JSON.parse(readFileSync4(resolved, "utf8")), fallbackName, { baseDir: dirname4(resolved) });
8565
+ return workflowBodyFromJson(JSON.parse(readFileSync5(resolved, "utf8")), fallbackName, { baseDir: dirname4(resolved) });
7950
8566
  } catch (error) {
7951
8567
  validationFailed(error, context);
7952
8568
  }
@@ -8021,6 +8637,21 @@ function workflowTimeoutMigrationName(workflow, timeoutMs) {
8021
8637
  const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
8022
8638
  return `${workflow.name}-${policy}-${suffix}`;
8023
8639
  }
8640
+ function workflowWithoutGoalWrapper(workflow, opts = {}) {
8641
+ return {
8642
+ body: {
8643
+ name: opts.name ?? workflow.name,
8644
+ description: workflow.description,
8645
+ version: workflow.version,
8646
+ steps: workflow.steps
8647
+ },
8648
+ changed: workflow.goal !== undefined
8649
+ };
8650
+ }
8651
+ function workflowGoalWrapperMigrationName(workflow) {
8652
+ const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
8653
+ return `${workflow.name}-no-workflow-goal-${suffix}`;
8654
+ }
8024
8655
  function printTextOutput(value) {
8025
8656
  for (const line of textOutputBlocks(value, { indent: " " }))
8026
8657
  console.log(line);
@@ -8072,6 +8703,10 @@ function timeoutDuration(raw, label) {
8072
8703
  throw new Error(`${label} must be a duration greater than zero, or none/unlimited`);
8073
8704
  return value;
8074
8705
  }
8706
+ function idleTimeoutDuration(raw, label) {
8707
+ const value = timeoutDuration(raw, label);
8708
+ return value === null ? undefined : value;
8709
+ }
8075
8710
  function parsePolicy(opts) {
8076
8711
  const catchUp = opts.catchUp ?? "latest";
8077
8712
  if (!["none", "latest", "all"].includes(catchUp))
@@ -8088,6 +8723,45 @@ function parsePolicy(opts) {
8088
8723
  leaseMs: positiveDuration(opts.lease, "--lease")
8089
8724
  };
8090
8725
  }
8726
+ function durationLabel(ms) {
8727
+ if (!ms || !Number.isFinite(ms))
8728
+ return "";
8729
+ const units = [
8730
+ [7 * 24 * 60 * 60 * 1000, "w"],
8731
+ [24 * 60 * 60 * 1000, "d"],
8732
+ [60 * 60 * 1000, "h"],
8733
+ [60 * 1000, "m"],
8734
+ [1000, "s"]
8735
+ ];
8736
+ for (const [unitMs, label] of units) {
8737
+ if (ms % unitMs === 0)
8738
+ return `${ms / unitMs}${label}`;
8739
+ }
8740
+ return `${ms}ms`;
8741
+ }
8742
+ function scheduleLabel(schedule) {
8743
+ if (schedule.type === "once")
8744
+ return `once:${schedule.at}`;
8745
+ if (schedule.type === "interval")
8746
+ return `every:${durationLabel(schedule.everyMs) || `${schedule.everyMs}ms`}`;
8747
+ if (schedule.type === "cron")
8748
+ return `cron:${schedule.expression}`;
8749
+ return schedule.minIntervalMs ? `dynamic:min-${durationLabel(schedule.minIntervalMs) || `${schedule.minIntervalMs}ms`}` : "dynamic";
8750
+ }
8751
+ function targetLabel(target) {
8752
+ if (target.type === "command")
8753
+ return `runs command ${target.command}`;
8754
+ if (target.type === "agent")
8755
+ return `runs ${target.provider} agent${target.cwd ? ` in ${target.cwd}` : ""}`;
8756
+ return `runs workflow ${target.workflowId}`;
8757
+ }
8758
+ function defaultLoopDescription(name, schedule, target) {
8759
+ return [
8760
+ `Why: keep ${name} running as an OpenLoops scheduled automation.`,
8761
+ `How: ${targetLabel(target)} on cadence ${scheduleLabel(schedule)}.`,
8762
+ "Outcome: record each run, status, retries, and evidence in OpenLoops for operator review."
8763
+ ].join(" ");
8764
+ }
8091
8765
  function baseCreateInput(name, opts, target) {
8092
8766
  const schedule = parseSchedule({
8093
8767
  at: typeof opts.at === "string" ? opts.at : undefined,
@@ -8103,9 +8777,10 @@ function baseCreateInput(name, opts, target) {
8103
8777
  retryDelay: typeof opts.retryDelay === "string" ? opts.retryDelay : undefined,
8104
8778
  lease: typeof opts.lease === "string" ? opts.lease : undefined
8105
8779
  });
8780
+ const explicitDescription = typeof opts.description === "string" && opts.description.trim() ? opts.description : undefined;
8106
8781
  return {
8107
8782
  name,
8108
- description: typeof opts.description === "string" ? opts.description : undefined,
8783
+ description: explicitDescription ?? defaultLoopDescription(name, schedule, target),
8109
8784
  schedule,
8110
8785
  target,
8111
8786
  goal: goalFromOpts(opts),
@@ -8228,7 +8903,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
8228
8903
  return {
8229
8904
  ok: result.status === 0,
8230
8905
  status: result.status,
8231
- stdout: readFileSync4(stdoutPath, "utf8"),
8906
+ stdout: readFileSync5(stdoutPath, "utf8"),
8232
8907
  stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
8233
8908
  error: result.error ? String(result.error.message || result.error) : ""
8234
8909
  };
@@ -8269,10 +8944,10 @@ function routeCursorsPath() {
8269
8944
  }
8270
8945
  function readRouteCursors() {
8271
8946
  const path = routeCursorsPath();
8272
- if (!existsSync5(path))
8947
+ if (!existsSync6(path))
8273
8948
  return {};
8274
8949
  try {
8275
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
8950
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
8276
8951
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
8277
8952
  } catch {
8278
8953
  return {};
@@ -8436,31 +9111,46 @@ function taskEventField(data, keys) {
8436
9111
  }
8437
9112
  return;
8438
9113
  }
8439
- function objectField(value) {
9114
+ function objectField2(value) {
8440
9115
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
8441
9116
  }
8442
9117
  function nestedObject(input, key) {
8443
- return objectField(input[key]);
9118
+ return objectField2(input[key]);
8444
9119
  }
8445
9120
  function taskEventRecords(data, metadata) {
8446
9121
  const records = [data];
8447
9122
  const dataTask = nestedObject(data, "task");
8448
- if (dataTask)
9123
+ if (dataTask) {
8449
9124
  records.push(dataTask);
9125
+ const dataTaskMetadata = nestedObject(dataTask, "metadata");
9126
+ if (dataTaskMetadata)
9127
+ records.push(dataTaskMetadata);
9128
+ }
8450
9129
  const dataPayload = nestedObject(data, "payload");
8451
9130
  if (dataPayload) {
8452
9131
  records.push(dataPayload);
9132
+ const payloadMetadata = nestedObject(dataPayload, "metadata");
9133
+ if (payloadMetadata)
9134
+ records.push(payloadMetadata);
8453
9135
  const payloadTask = nestedObject(dataPayload, "task");
8454
- if (payloadTask)
9136
+ if (payloadTask) {
8455
9137
  records.push(payloadTask);
9138
+ const payloadTaskMetadata = nestedObject(payloadTask, "metadata");
9139
+ if (payloadTaskMetadata)
9140
+ records.push(payloadTaskMetadata);
9141
+ }
8456
9142
  }
8457
9143
  const dataMetadata = nestedObject(data, "metadata");
8458
9144
  if (dataMetadata)
8459
9145
  records.push(dataMetadata);
8460
9146
  records.push(metadata);
8461
9147
  const metadataTask = nestedObject(metadata, "task");
8462
- if (metadataTask)
9148
+ if (metadataTask) {
8463
9149
  records.push(metadataTask);
9150
+ const metadataTaskMetadata = nestedObject(metadataTask, "metadata");
9151
+ if (metadataTaskMetadata)
9152
+ records.push(metadataTaskMetadata);
9153
+ }
8464
9154
  const metadataAutomation = nestedObject(metadata, "automation");
8465
9155
  if (metadataAutomation)
8466
9156
  records.push(metadataAutomation);
@@ -8472,6 +9162,15 @@ function booleanLike(value) {
8472
9162
  function hasTruthyField(records, keys) {
8473
9163
  return records.some((record) => keys.some((key) => booleanLike(record[key])));
8474
9164
  }
9165
+ function firstTruthyField(records, keys) {
9166
+ for (const record of records) {
9167
+ for (const key of keys) {
9168
+ if (booleanLike(record[key]))
9169
+ return key;
9170
+ }
9171
+ }
9172
+ return;
9173
+ }
8475
9174
  function automationRecords(data, metadata) {
8476
9175
  const records = [];
8477
9176
  const dataAutomation = nestedObject(data, "automation");
@@ -8481,14 +9180,26 @@ function automationRecords(data, metadata) {
8481
9180
  const dataTaskAutomation = dataTask ? nestedObject(dataTask, "automation") : undefined;
8482
9181
  if (dataTaskAutomation)
8483
9182
  records.push(dataTaskAutomation);
9183
+ const dataTaskMetadata = dataTask ? nestedObject(dataTask, "metadata") : undefined;
9184
+ const dataTaskMetadataAutomation = dataTaskMetadata ? nestedObject(dataTaskMetadata, "automation") : undefined;
9185
+ if (dataTaskMetadataAutomation)
9186
+ records.push(dataTaskMetadataAutomation);
8484
9187
  const dataPayload = nestedObject(data, "payload");
8485
9188
  const payloadAutomation = dataPayload ? nestedObject(dataPayload, "automation") : undefined;
8486
9189
  if (payloadAutomation)
8487
9190
  records.push(payloadAutomation);
9191
+ const payloadMetadata = dataPayload ? nestedObject(dataPayload, "metadata") : undefined;
9192
+ const payloadMetadataAutomation = payloadMetadata ? nestedObject(payloadMetadata, "automation") : undefined;
9193
+ if (payloadMetadataAutomation)
9194
+ records.push(payloadMetadataAutomation);
8488
9195
  const payloadTask = dataPayload ? nestedObject(dataPayload, "task") : undefined;
8489
9196
  const payloadTaskAutomation = payloadTask ? nestedObject(payloadTask, "automation") : undefined;
8490
9197
  if (payloadTaskAutomation)
8491
9198
  records.push(payloadTaskAutomation);
9199
+ const payloadTaskMetadata = payloadTask ? nestedObject(payloadTask, "metadata") : undefined;
9200
+ const payloadTaskMetadataAutomation = payloadTaskMetadata ? nestedObject(payloadTaskMetadata, "automation") : undefined;
9201
+ if (payloadTaskMetadataAutomation)
9202
+ records.push(payloadTaskMetadataAutomation);
8492
9203
  const dataMetadata = nestedObject(data, "metadata");
8493
9204
  const dataMetadataAutomation = dataMetadata ? nestedObject(dataMetadata, "automation") : undefined;
8494
9205
  if (dataMetadataAutomation)
@@ -8500,9 +9211,13 @@ function automationRecords(data, metadata) {
8500
9211
  const metadataTaskAutomation = metadataTask ? nestedObject(metadataTask, "automation") : undefined;
8501
9212
  if (metadataTaskAutomation)
8502
9213
  records.push(metadataTaskAutomation);
9214
+ const metadataTaskMetadata = metadataTask ? nestedObject(metadataTask, "metadata") : undefined;
9215
+ const metadataTaskMetadataAutomation = metadataTaskMetadata ? nestedObject(metadataTaskMetadata, "automation") : undefined;
9216
+ if (metadataTaskMetadataAutomation)
9217
+ records.push(metadataTaskMetadataAutomation);
8503
9218
  return records;
8504
9219
  }
8505
- function tagsFromValue(value) {
9220
+ function tagsFromValue2(value) {
8506
9221
  if (Array.isArray(value))
8507
9222
  return value.map((entry) => String(entry).trim()).filter(Boolean);
8508
9223
  if (typeof value === "string")
@@ -8512,40 +9227,381 @@ function tagsFromValue(value) {
8512
9227
  function taskEventTags(records) {
8513
9228
  const tags = new Set;
8514
9229
  for (const record of records) {
8515
- for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags))
9230
+ for (const tag of tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags))
8516
9231
  tags.add(tag);
8517
9232
  }
8518
9233
  return [...tags];
8519
9234
  }
9235
+ var ROUTE_DISALLOWED_TASK_TAGS = new Set([
9236
+ "no-auto",
9237
+ "manual",
9238
+ "manual-required",
9239
+ "approval-required",
9240
+ "blocked",
9241
+ "completed",
9242
+ "done",
9243
+ "cancelled",
9244
+ "canceled",
9245
+ "failed",
9246
+ "archived"
9247
+ ]);
9248
+ var ROUTE_MANUAL_GATE_FIELDS = [
9249
+ "no_auto",
9250
+ "noAuto",
9251
+ "manual",
9252
+ "manual_required",
9253
+ "manualRequired",
9254
+ "requires_approval",
9255
+ "requiresApproval",
9256
+ "approval_required",
9257
+ "approvalRequired"
9258
+ ];
8520
9259
  function taskRouteEligibility(data, metadata) {
8521
9260
  const records = taskEventRecords(data, metadata);
9261
+ const automation = automationRecords(data, metadata);
8522
9262
  const tags = taskEventTags(records);
8523
- const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automationRecords(data, metadata), ["allowed"]);
9263
+ const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automation, ["allowed"]);
8524
9264
  if (!hasRouteOptIn)
8525
9265
  return { eligible: false, reason: "missing explicit route opt-in", tags };
8526
9266
  const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
8527
9267
  if (status && ["blocked", "completed", "done", "cancelled", "canceled", "failed", "archived"].includes(status)) {
8528
9268
  return { eligible: false, reason: `task status is not routable: ${status}`, tags };
8529
9269
  }
8530
- const disallowedTags = tags.filter((tag) => ["no-auto", "manual", "manual-required", "approval-required"].includes(tag));
9270
+ const disallowedTags = tags.filter((tag) => ROUTE_DISALLOWED_TASK_TAGS.has(tag.toLowerCase()));
8531
9271
  if (disallowedTags.length)
8532
9272
  return { eligible: false, reason: `task has disallowed tag: ${disallowedTags[0]}`, tags };
8533
- if (hasTruthyField(records, [
8534
- "no_auto",
8535
- "noAuto",
8536
- "manual",
8537
- "manual_required",
8538
- "manualRequired",
8539
- "requires_approval",
8540
- "requiresApproval",
8541
- "approval_required",
8542
- "approvalRequired"
8543
- ])) {
8544
- return { eligible: false, reason: "task metadata requires manual or approval-gated handling", tags };
9273
+ const manualGateField = firstTruthyField([...records, ...automation], ROUTE_MANUAL_GATE_FIELDS);
9274
+ if (manualGateField) {
9275
+ return { eligible: false, reason: `task metadata requires manual or approval-gated handling: ${manualGateField}`, tags };
8545
9276
  }
8546
9277
  return { eligible: true, tags };
8547
9278
  }
8548
- function skippedDrainTask(task, event, reason) {
9279
+ var PR_AUTHOR_FIELDS = [
9280
+ "github_author",
9281
+ "githubAuthor",
9282
+ "github_pr_author",
9283
+ "githubPrAuthor",
9284
+ "pr_author",
9285
+ "prAuthor",
9286
+ "pull_request_author",
9287
+ "pullRequestAuthor",
9288
+ "author_login",
9289
+ "authorLogin"
9290
+ ];
9291
+ var PR_REVIEWER_FIELDS = [
9292
+ "github_reviewer",
9293
+ "githubReviewer",
9294
+ "github_review_actor",
9295
+ "githubReviewActor",
9296
+ "github_review_login",
9297
+ "githubReviewLogin",
9298
+ "github_actor",
9299
+ "githubActor",
9300
+ "reviewer_login",
9301
+ "reviewerLogin",
9302
+ "review_actor",
9303
+ "reviewActor",
9304
+ "merge_actor",
9305
+ "mergeActor"
9306
+ ];
9307
+ var PR_REVIEWER_POOL_FIELDS = [
9308
+ "github_reviewer_pool",
9309
+ "githubReviewerPool",
9310
+ "github_review_pool",
9311
+ "githubReviewPool",
9312
+ "github_reviewers",
9313
+ "githubReviewers",
9314
+ "reviewer_pool",
9315
+ "reviewerPool",
9316
+ "reviewers"
9317
+ ];
9318
+ var PR_REVIEW_REQUIRED_FIELDS = [
9319
+ "github_review_required",
9320
+ "githubReviewRequired",
9321
+ "pr_review_required",
9322
+ "prReviewRequired",
9323
+ "review_required",
9324
+ "reviewRequired",
9325
+ "requires_non_author_review",
9326
+ "requiresNonAuthorReview",
9327
+ "branch_protection_review_required",
9328
+ "branchProtectionReviewRequired"
9329
+ ];
9330
+ function githubLogin(value) {
9331
+ if (!value?.trim())
9332
+ return;
9333
+ const login = value.trim().replace(/^@/, "");
9334
+ return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) ? login : undefined;
9335
+ }
9336
+ function githubLogins(values) {
9337
+ const seen = new Set;
9338
+ const logins = [];
9339
+ for (const value of values) {
9340
+ const login = githubLogin(value);
9341
+ if (!login)
9342
+ continue;
9343
+ const key = login.toLowerCase();
9344
+ if (seen.has(key))
9345
+ continue;
9346
+ seen.add(key);
9347
+ logins.push(login);
9348
+ }
9349
+ return logins;
9350
+ }
9351
+ function routeEvidenceText(records) {
9352
+ const fields = [
9353
+ "title",
9354
+ "task_title",
9355
+ "taskTitle",
9356
+ "description",
9357
+ "body",
9358
+ "reason",
9359
+ "message",
9360
+ "fingerprint",
9361
+ "reviewDecision",
9362
+ "review_decision",
9363
+ "mergeStateStatus",
9364
+ "merge_state_status"
9365
+ ];
9366
+ const values = [];
9367
+ for (const record of records) {
9368
+ for (const field of fields)
9369
+ values.push(...recordFieldValues(record, field));
9370
+ values.push(...tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags));
9371
+ }
9372
+ return values.join(`
9373
+ `);
9374
+ }
9375
+ function authorFromPrText(text) {
9376
+ const patterns = [
9377
+ /\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
9378
+ /\bPR\s*#?\d+\s+author\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
9379
+ /\bgithub\s+author\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i
9380
+ ];
9381
+ for (const pattern of patterns) {
9382
+ const match = pattern.exec(text);
9383
+ const login = githubLogin(match?.[1]);
9384
+ if (login)
9385
+ return login;
9386
+ }
9387
+ return;
9388
+ }
9389
+ function prReviewRoutingDecision(data, metadata, opts) {
9390
+ const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
9391
+ const text = routeEvidenceText(records);
9392
+ const signals = [];
9393
+ const hasPrReference = /github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i.test(text) || /\bgithub-pr:[^\s]+#\d+/i.test(text) || /\bpull request\b/i.test(text) || /\bpr\s*#?\d+\b/i.test(text);
9394
+ const reviewRequiredByField = hasTruthyField(records, PR_REVIEW_REQUIRED_FIELDS);
9395
+ const reviewRequiredByText = /reviewdecision\s*[:=]\s*review_required/i.test(text) || /\breview_required\b/i.test(text) || /\breview required\b/i.test(text) || /\brequires?\s+\d+\s+approving review/i.test(text) || /\bbranch protection\b[\s\S]{0,160}\bapproving review/i.test(text);
9396
+ const mergeBlockedByText = /mergestatestatus\s*[:=]\s*blocked/i.test(text) || /\bmerge state status\b[\s:=]+blocked/i.test(text);
9397
+ const approvalIntent = /\b(approve|approval|merge|review)\b/i.test(text);
9398
+ if (reviewRequiredByField)
9399
+ signals.push("review-required-field");
9400
+ if (reviewRequiredByText)
9401
+ signals.push("review-required-text");
9402
+ if (mergeBlockedByText)
9403
+ signals.push("merge-blocked-text");
9404
+ if (approvalIntent && hasPrReference)
9405
+ signals.push("pr-approval-intent");
9406
+ const required = hasPrReference && (reviewRequiredByField || reviewRequiredByText || mergeBlockedByText || approvalIntent);
9407
+ if (!required)
9408
+ return { required: false, allowed: true, reviewers: [], signals };
9409
+ const author = githubLogin(firstRouteField(records, PR_AUTHOR_FIELDS)) ?? authorFromPrText(text);
9410
+ const reviewers = githubLogins([
9411
+ opts.githubReviewer,
9412
+ ...splitList(opts.githubReviewerPool) ?? [],
9413
+ ...PR_REVIEWER_FIELDS.flatMap((field) => routeFieldValues(records, field)),
9414
+ ...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field))
9415
+ ]);
9416
+ const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
9417
+ if (!author) {
9418
+ return {
9419
+ required: true,
9420
+ allowed: false,
9421
+ reason: "PR approval/merge route requires PR author evidence before selecting a non-author GitHub reviewer",
9422
+ reviewers,
9423
+ signals
9424
+ };
9425
+ }
9426
+ if (!reviewers.length) {
9427
+ return {
9428
+ required: true,
9429
+ allowed: false,
9430
+ reason: "PR approval/merge route requires --github-reviewer, --github-reviewer-pool, or task metadata github_reviewer/github_reviewer_pool with a login different from the PR author",
9431
+ author,
9432
+ reviewers,
9433
+ signals
9434
+ };
9435
+ }
9436
+ if (!selectedReviewer) {
9437
+ return {
9438
+ required: true,
9439
+ allowed: false,
9440
+ reason: `PR approval/merge route reviewer candidates match PR author ${author}; self-review is not routable`,
9441
+ author,
9442
+ reviewers,
9443
+ signals
9444
+ };
9445
+ }
9446
+ return {
9447
+ required: true,
9448
+ allowed: true,
9449
+ author,
9450
+ reviewers,
9451
+ selectedReviewer,
9452
+ signals
9453
+ };
9454
+ }
9455
+ var SUPPORTED_AGENT_PROVIDERS = new Set(["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"]);
9456
+ var PROVIDER_HINT_FIELDS = ["provider_hint", "providerHint", "route_provider", "routeProvider", "agent_provider", "agentProvider"];
9457
+ var AUTH_PROFILE_HINT_FIELDS = ["auth_profile", "authProfile", "codewith_profile", "codewithProfile", "profile"];
9458
+ var AUTH_PROFILE_POOL_HINT_FIELDS = ["auth_profile_pool", "authProfilePool", "codewith_profile_pool", "codewithProfilePool", "profile_pool", "profilePool"];
9459
+ var ACCOUNT_HINT_FIELDS = ["account", "account_profile", "accountProfile", "openaccounts_profile", "openaccountsProfile"];
9460
+ var ACCOUNT_POOL_HINT_FIELDS = ["account_pool", "accountPool", "openaccounts_pool", "openaccountsPool"];
9461
+ var ACCOUNT_TOOL_HINT_FIELDS = ["account_tool", "accountTool", "openaccounts_tool", "openaccountsTool"];
9462
+ function canonicalRouteField(value) {
9463
+ return value.replace(/[^a-z0-9]/gi, "").toLowerCase();
9464
+ }
9465
+ function normalizeAgentProvider(value, source) {
9466
+ const provider = value?.trim().toLowerCase();
9467
+ if (provider && SUPPORTED_AGENT_PROVIDERS.has(provider))
9468
+ return provider;
9469
+ const expected = [...SUPPORTED_AGENT_PROVIDERS].join(", ");
9470
+ throw new Error(`unsupported provider${provider ? ` "${provider}"` : ""} from ${source}; expected one of: ${expected}`);
9471
+ }
9472
+ function stringValuesFromUnknown(value) {
9473
+ if (Array.isArray(value))
9474
+ return value.flatMap((entry) => stringValuesFromUnknown(entry));
9475
+ if (typeof value === "string")
9476
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
9477
+ if (typeof value === "number" || typeof value === "boolean")
9478
+ return [String(value)];
9479
+ return [];
9480
+ }
9481
+ function recordFieldValues(record, field) {
9482
+ const expected = canonicalRouteField(field);
9483
+ const values = [];
9484
+ for (const [key, value] of Object.entries(record)) {
9485
+ if (canonicalRouteField(key) === expected)
9486
+ values.push(...stringValuesFromUnknown(value));
9487
+ }
9488
+ return values;
9489
+ }
9490
+ function routeFieldValues(records, field) {
9491
+ if (canonicalRouteField(field) === "tags")
9492
+ return taskEventTags(records);
9493
+ return records.flatMap((record) => recordFieldValues(record, field));
9494
+ }
9495
+ function firstRouteField(records, fields) {
9496
+ for (const field of fields) {
9497
+ const value = routeFieldValues(records, field).find(Boolean);
9498
+ if (value)
9499
+ return value;
9500
+ }
9501
+ return;
9502
+ }
9503
+ function routeFieldList(records, fields) {
9504
+ for (const field of fields) {
9505
+ const values = routeFieldValues(records, field);
9506
+ if (values.length)
9507
+ return values;
9508
+ }
9509
+ return;
9510
+ }
9511
+ function parseProviderRoutingRule(raw) {
9512
+ const rule = raw.trim();
9513
+ const selectorIndex = rule.indexOf(":");
9514
+ if (selectorIndex <= 0)
9515
+ throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
9516
+ const selector = rule.slice(0, selectorIndex).trim();
9517
+ const route = rule.slice(selectorIndex + 1).trim();
9518
+ const equalsIndex = selector.indexOf("=");
9519
+ if (equalsIndex <= 0)
9520
+ throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
9521
+ const field = selector.slice(0, equalsIndex).trim();
9522
+ const value = selector.slice(equalsIndex + 1).trim();
9523
+ const providerIndex = route.indexOf(":");
9524
+ const providerRaw = providerIndex >= 0 ? route.slice(0, providerIndex).trim() : route;
9525
+ const profilesRaw = providerIndex >= 0 ? route.slice(providerIndex + 1).trim() : undefined;
9526
+ if (!field || !value || !providerRaw)
9527
+ throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
9528
+ return {
9529
+ raw,
9530
+ field,
9531
+ value,
9532
+ provider: normalizeAgentProvider(providerRaw, `provider rule ${field}=${value}`),
9533
+ profiles: splitList(profilesRaw)
9534
+ };
9535
+ }
9536
+ function parseProviderRoutingRules(values) {
9537
+ return (values ?? []).map(parseProviderRoutingRule);
9538
+ }
9539
+ function providerRuleMatches(rule, records) {
9540
+ const expected = rule.value.trim().toLowerCase();
9541
+ return routeFieldValues(records, rule.field).some((value) => value.trim().toLowerCase() === expected);
9542
+ }
9543
+ function accountRef(profile, tool) {
9544
+ return profile ? { profile, tool } : undefined;
9545
+ }
9546
+ function accountRefs(profiles, tool) {
9547
+ return profiles?.length ? profiles.map((profile) => ({ profile, tool })) : undefined;
9548
+ }
9549
+ function providerRoutingPublic(decision) {
9550
+ return {
9551
+ provider: decision.provider,
9552
+ source: decision.source,
9553
+ reason: decision.reason,
9554
+ rule: decision.rule,
9555
+ authProfile: decision.authProfile,
9556
+ authProfilePool: decision.authProfilePool,
9557
+ account: decision.account,
9558
+ accountPool: decision.accountPool
9559
+ };
9560
+ }
9561
+ function resolveProviderRouting(data, metadata, opts) {
9562
+ const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
9563
+ const matchedRule = parseProviderRoutingRules(opts.providerRule).find((rule) => providerRuleMatches(rule, records));
9564
+ const metadataProvider = matchedRule || opts.provider ? undefined : firstRouteField(records, PROVIDER_HINT_FIELDS);
9565
+ const provider = matchedRule ? matchedRule.provider : opts.provider ? normalizeAgentProvider(opts.provider, "--provider") : metadataProvider ? normalizeAgentProvider(metadataProvider, "task metadata provider hint") : "codewith";
9566
+ const source = matchedRule ? "rule" : opts.provider ? "option" : metadataProvider ? "metadata" : "default";
9567
+ const metadataProfilePool = routeFieldList(records, provider === "codewith" ? AUTH_PROFILE_POOL_HINT_FIELDS : [...ACCOUNT_POOL_HINT_FIELDS, ...AUTH_PROFILE_POOL_HINT_FIELDS]);
9568
+ const metadataProfile = firstRouteField(records, provider === "codewith" ? AUTH_PROFILE_HINT_FIELDS : [...ACCOUNT_HINT_FIELDS, ...AUTH_PROFILE_HINT_FIELDS]);
9569
+ const profilePool = matchedRule?.profiles ?? metadataProfilePool;
9570
+ const profile = metadataProfile;
9571
+ const metadataAccountTool = firstRouteField(records, ACCOUNT_TOOL_HINT_FIELDS);
9572
+ const accountTool = opts.accountTool ?? (matchedRule?.profiles ? undefined : metadataAccountTool) ?? (provider === "codewith" ? undefined : provider);
9573
+ const hasResolvedAccountPool = provider !== "codewith" && Boolean((profilePool?.length ?? 0) || profile);
9574
+ if (opts.accountTool && !opts.account && !opts.accountPool && !opts.triageAccount && !opts.plannerAccount && !opts.workerAccount && !opts.verifierAccount && !hasResolvedAccountPool) {
9575
+ throw new Error("--account-tool requires --account, --account-pool, --triage-account, --planner-account, --worker-account, --verifier-account, metadata account hints, or provider-rule profiles");
9576
+ }
9577
+ if (provider === "codewith") {
9578
+ const cliAuthProfilePool = splitList(opts.authProfilePool);
9579
+ return {
9580
+ provider,
9581
+ source,
9582
+ reason: matchedRule ? `matched provider rule ${matchedRule.field}=${matchedRule.value}` : metadataProvider ? "selected provider from task metadata" : opts.provider ? "selected provider from --provider" : "selected default provider",
9583
+ rule: matchedRule,
9584
+ authProfile: opts.authProfile ?? profile,
9585
+ authProfilePool: matchedRule?.profiles ?? cliAuthProfilePool ?? (opts.authProfile ? undefined : metadataProfilePool),
9586
+ account: accountRef(opts.account, opts.accountTool),
9587
+ accountPool: accountPoolFromOpts(opts)
9588
+ };
9589
+ }
9590
+ if (opts.authProfile || opts.authProfilePool) {
9591
+ throw new Error(`--auth-profile and --auth-profile-pool are supported only for --provider codewith; use OpenAccounts account profiles for ${provider}`);
9592
+ }
9593
+ const cliAccountPool = accountPoolFromOpts(opts);
9594
+ const account = opts.account ? accountRef(opts.account, opts.accountTool ?? provider) : accountRef(profile, accountTool);
9595
+ return {
9596
+ provider,
9597
+ source,
9598
+ reason: matchedRule ? `matched provider rule ${matchedRule.field}=${matchedRule.value}` : metadataProvider ? "selected provider from task metadata" : opts.provider ? "selected provider from --provider" : "selected default provider",
9599
+ rule: matchedRule,
9600
+ account,
9601
+ accountPool: matchedRule?.profiles ? accountRefs(matchedRule.profiles, accountTool) : cliAccountPool ?? (opts.account ? undefined : accountRefs(metadataProfilePool, accountTool))
9602
+ };
9603
+ }
9604
+ function skippedDrainTask(task, event, reason, extra = {}) {
8549
9605
  const taskId = taskField(task, ["id", "task_id", "taskId"]) ?? event?.subject ?? "unknown";
8550
9606
  return {
8551
9607
  kind: "skipped",
@@ -8554,7 +9610,8 @@ function skippedDrainTask(task, event, reason) {
8554
9610
  reason,
8555
9611
  taskId,
8556
9612
  event,
8557
- routeError: true
9613
+ routeError: true,
9614
+ ...extra
8558
9615
  },
8559
9616
  human: `skipped task ${taskId}: ${reason}`
8560
9617
  };
@@ -8562,6 +9619,32 @@ function skippedDrainTask(task, event, reason) {
8562
9619
  function isSkippableDrainRouteError(message) {
8563
9620
  return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
8564
9621
  }
9622
+ function todosMutationSummary(result) {
9623
+ return {
9624
+ ok: result.ok,
9625
+ status: result.status,
9626
+ error: result.ok ? undefined : redact(result.stderr || result.error || "todos mutation failed", 320)
9627
+ };
9628
+ }
9629
+ function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
9630
+ const taskId = taskField(task, ["id", "task_id", "taskId"]);
9631
+ if (!taskId)
9632
+ return { attempted: false, reason: "task id missing" };
9633
+ const comment = `OpenLoops route blocked for task ${taskId}: ${reason}. Added no-auto and removed auto:route so route drains do not repeatedly route this task until its project path is fixed.`;
9634
+ const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
9635
+ const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
9636
+ const untagResult = runLocalCommand("todos", ["--project", todosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
9637
+ const ok = commentResult.ok && tagResult.ok && untagResult.ok;
9638
+ return {
9639
+ ok,
9640
+ attempted: true,
9641
+ taskId,
9642
+ error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
9643
+ comment: todosMutationSummary(commentResult),
9644
+ tagNoAuto: todosMutationSummary(tagResult),
9645
+ untagAutoRoute: todosMutationSummary(untagResult)
9646
+ };
9647
+ }
8565
9648
  function generatedRouteSandboxPreflight(workflow) {
8566
9649
  const checks = [];
8567
9650
  for (const step of workflow.steps) {
@@ -8687,6 +9770,28 @@ var TODOS_TASK_ROUTE_TEMPLATE_IDS = new Set([
8687
9770
  TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
8688
9771
  TASK_LIFECYCLE_TEMPLATE_ID
8689
9772
  ]);
9773
+ var UNCLEARED_ROUTE_WORK_ITEM_STATUSES = new Set([
9774
+ "admitted",
9775
+ "running",
9776
+ "succeeded",
9777
+ "failed",
9778
+ "dead_letter",
9779
+ "cancelled"
9780
+ ]);
9781
+ function isUnclearedRouteWorkItem(item) {
9782
+ return UNCLEARED_ROUTE_WORK_ITEM_STATUSES.has(item.status);
9783
+ }
9784
+ function isExistingGitProjectPath(path) {
9785
+ const result = spawnSync5("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
9786
+ return result.status === 0;
9787
+ }
9788
+ function validateRequiredRouteWorktreeProjectPath(opts, projectPath) {
9789
+ if ((opts.worktreeMode ?? "auto") !== "required")
9790
+ return;
9791
+ if (!isExistingGitProjectPath(projectPath)) {
9792
+ throw new Error(`worktreeMode=required but projectPath is not an existing git repository: ${projectPath}`);
9793
+ }
9794
+ }
8690
9795
  function todosTaskRouteTemplateId(opts) {
8691
9796
  const id = (opts.template ?? TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).trim();
8692
9797
  if (!TODOS_TASK_ROUTE_TEMPLATE_IDS.has(id)) {
@@ -8695,7 +9800,7 @@ function todosTaskRouteTemplateId(opts) {
8695
9800
  return id;
8696
9801
  }
8697
9802
  async function readEventEnvelopeInput(opts = {}) {
8698
- const raw = opts.eventJson ?? (opts.eventFile ? readFileSync4(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
9803
+ const raw = opts.eventJson ?? (opts.eventFile ? readFileSync5(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
8699
9804
  const event = JSON.parse(raw);
8700
9805
  if (!event || typeof event !== "object" || Array.isArray(event))
8701
9806
  throw new Error("event JSON must be an object");
@@ -8748,8 +9853,8 @@ function routeTodosTaskEvent(event, opts) {
8748
9853
  const store2 = new Store;
8749
9854
  try {
8750
9855
  const existingItem = store2.findWorkflowWorkItem("todos-task", idempotencyKey);
8751
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8752
- const existingLoop = store2.getLoop(existingItem.loopId);
9856
+ if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
9857
+ const existingLoop = existingItem.loopId ? store2.getLoop(existingItem.loopId) : undefined;
8753
9858
  const existingWorkflow = existingItem.workflowId ? store2.getWorkflow(existingItem.workflowId) : undefined;
8754
9859
  const existingInvocation = store2.getWorkflowInvocation(existingItem.invocationId);
8755
9860
  return {
@@ -8771,12 +9876,27 @@ function routeTodosTaskEvent(event, opts) {
8771
9876
  store2.close();
8772
9877
  }
8773
9878
  }
8774
- const provider = opts.provider ?? "codewith";
8775
- if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
8776
- throw new Error("unsupported provider");
9879
+ validateRequiredRouteWorktreeProjectPath(opts, projectPath);
9880
+ const prReviewRouting = prReviewRoutingDecision(data, metadata, opts);
9881
+ if (prReviewRouting.required && !prReviewRouting.allowed) {
9882
+ return {
9883
+ kind: "skipped",
9884
+ value: {
9885
+ skipped: true,
9886
+ reason: prReviewRouting.reason,
9887
+ event,
9888
+ taskId,
9889
+ routeError: true,
9890
+ prReviewRouting
9891
+ },
9892
+ human: `skipped task ${taskId}: ${prReviewRouting.reason}`
9893
+ };
9894
+ }
9895
+ const providerRouting = resolveProviderRouting(data, metadata, opts);
9896
+ const provider = providerRouting.provider;
8777
9897
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
8778
9898
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
8779
- const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
9899
+ const authProfile = providerAuthProfileFromOpts({ authProfile: providerRouting.authProfile }, provider);
8780
9900
  const templateId = todosTaskRouteTemplateId(opts);
8781
9901
  const workflowInput = {
8782
9902
  taskId,
@@ -8787,13 +9907,13 @@ function routeTodosTaskEvent(event, opts) {
8787
9907
  projectGroup,
8788
9908
  provider,
8789
9909
  authProfile,
8790
- authProfilePool: splitList(opts.authProfilePool),
9910
+ authProfilePool: providerRouting.authProfilePool,
8791
9911
  triageAuthProfile: opts.triageAuthProfile,
8792
9912
  plannerAuthProfile: opts.plannerAuthProfile,
8793
9913
  workerAuthProfile: opts.workerAuthProfile,
8794
9914
  verifierAuthProfile: opts.verifierAuthProfile,
8795
- account: accountFromOpts(opts),
8796
- accountPool: accountPoolFromOpts(opts),
9915
+ account: providerRouting.account,
9916
+ accountPool: providerRouting.accountPool,
8797
9917
  triageAccount: roleAccountFromOpts(opts, opts.triageAccount),
8798
9918
  plannerAccount: roleAccountFromOpts(opts, opts.plannerAccount),
8799
9919
  workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
@@ -8803,12 +9923,14 @@ function routeTodosTaskEvent(event, opts) {
8803
9923
  agent: opts.agent,
8804
9924
  addDirs: listFromRepeatedOpts(opts.addDir),
8805
9925
  timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
9926
+ verifierIdleTimeoutMs: idleTimeoutDuration(opts.verifierIdleTimeout, "--verifier-idle-timeout"),
8806
9927
  permissionMode,
8807
9928
  sandbox,
8808
9929
  manualBreakGlass: Boolean(opts.manualBreakGlass),
8809
9930
  worktreeMode: opts.worktreeMode ?? "auto",
8810
9931
  worktreeRoot: opts.worktreeRoot,
8811
9932
  worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
9933
+ prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
8812
9934
  eventId: event.id,
8813
9935
  eventType: event.type,
8814
9936
  todosProjectPath: opts.todosProject
@@ -8844,7 +9966,10 @@ function routeTodosTaskEvent(event, opts) {
8844
9966
  worktreePolicy: opts.worktreeMode ?? "auto",
8845
9967
  permissions: permissionMode,
8846
9968
  manualBreakGlass: Boolean(opts.manualBreakGlass),
8847
- accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
9969
+ prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
9970
+ accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
9971
+ providerRouting: providerRoutingPublic(providerRouting),
9972
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
8848
9973
  concurrencyGroup: projectGroup ?? routeProjectPath
8849
9974
  },
8850
9975
  outputPolicy: {
@@ -8883,7 +10008,7 @@ function routeTodosTaskEvent(event, opts) {
8883
10008
  }, {}) : undefined;
8884
10009
  return {
8885
10010
  kind: "created",
8886
- value: { deduped: false, idempotencyKey, event, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
10011
+ value: { deduped: false, idempotencyKey, event, providerRouting: providerRoutingPublic(providerRouting), prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
8887
10012
  human: `dry-run ${loopName}`
8888
10013
  };
8889
10014
  }
@@ -8899,8 +10024,8 @@ function routeTodosTaskEvent(event, opts) {
8899
10024
  const outcome = store.writeTransaction(() => {
8900
10025
  const invocation = store.createWorkflowInvocation(invocationInput);
8901
10026
  const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
8902
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8903
- const existingLoop = store.getLoop(existingItem.loopId);
10027
+ if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
10028
+ const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
8904
10029
  const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
8905
10030
  return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
8906
10031
  }
@@ -8911,6 +10036,11 @@ function routeTodosTaskEvent(event, opts) {
8911
10036
  status: throttle && !throttle.allowed ? "deferred" : "queued",
8912
10037
  lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
8913
10038
  });
10039
+ const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
10040
+ previousWorkItemId: workItem.id,
10041
+ previousAttempts: workItem.attempts,
10042
+ reason: workItem.lastReason
10043
+ } : undefined;
8914
10044
  const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
8915
10045
  if (throttle && !throttle.allowed)
8916
10046
  return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
@@ -8927,7 +10057,15 @@ function routeTodosTaskEvent(event, opts) {
8927
10057
  }
8928
10058
  });
8929
10059
  const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by todos-task route" });
8930
- return { kind: "created", invocation: refreshedInvocation, workItem: admitted, workflow, loop, throttle };
10060
+ return {
10061
+ kind: "created",
10062
+ invocation: refreshedInvocation,
10063
+ workItem: admitted,
10064
+ workflow,
10065
+ loop,
10066
+ throttle,
10067
+ requeue: requeue ? { ...requeue, attempt: admitted.attempts, newWorkflowId: workflow.id, newLoopId: loop.id } : undefined
10068
+ };
8931
10069
  });
8932
10070
  if (outcome.kind === "deduped") {
8933
10071
  return {
@@ -8956,6 +10094,8 @@ function routeTodosTaskEvent(event, opts) {
8956
10094
  event,
8957
10095
  invocation: publicWorkflowInvocation(outcome.invocation),
8958
10096
  workItem: publicWorkflowWorkItem(outcome.workItem),
10097
+ providerRouting: providerRoutingPublic(providerRouting),
10098
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
8959
10099
  throttle: outcome.throttle,
8960
10100
  workflow: workflowBody,
8961
10101
  loop: loopInput
@@ -8971,8 +10111,11 @@ function routeTodosTaskEvent(event, opts) {
8971
10111
  event,
8972
10112
  invocation: publicWorkflowInvocation(outcome.invocation),
8973
10113
  workItem: publicWorkflowWorkItem(outcome.workItem),
10114
+ providerRouting: providerRoutingPublic(providerRouting),
10115
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
8974
10116
  workflow: publicWorkflow(outcome.workflow),
8975
10117
  loop: publicLoop(outcome.loop),
10118
+ requeue: outcome.requeue,
8976
10119
  throttle: outcome.throttle,
8977
10120
  sandboxPreflight,
8978
10121
  preflight
@@ -8996,12 +10139,11 @@ function routeGenericEvent(event, opts) {
8996
10139
  const workflowName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:workflow`;
8997
10140
  const loopName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:run`;
8998
10141
  const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
8999
- const provider = opts.provider ?? "codewith";
9000
- if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
9001
- throw new Error("unsupported provider");
10142
+ const providerRouting = resolveProviderRouting(data, metadata, opts);
10143
+ const provider = providerRouting.provider;
9002
10144
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
9003
10145
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
9004
- const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
10146
+ const authProfile = providerAuthProfileFromOpts({ authProfile: providerRouting.authProfile }, provider);
9005
10147
  let workflowBody = renderEventWorkerVerifierWorkflow({
9006
10148
  eventId: event.id,
9007
10149
  eventType: event.type,
@@ -9014,11 +10156,11 @@ function routeGenericEvent(event, opts) {
9014
10156
  projectGroup,
9015
10157
  provider,
9016
10158
  authProfile,
9017
- authProfilePool: splitList(opts.authProfilePool),
10159
+ authProfilePool: providerRouting.authProfilePool,
9018
10160
  workerAuthProfile: opts.workerAuthProfile,
9019
10161
  verifierAuthProfile: opts.verifierAuthProfile,
9020
- account: accountFromOpts(opts),
9021
- accountPool: accountPoolFromOpts(opts),
10162
+ account: providerRouting.account,
10163
+ accountPool: providerRouting.accountPool,
9022
10164
  workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
9023
10165
  verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
9024
10166
  model: opts.model,
@@ -9026,6 +10168,7 @@ function routeGenericEvent(event, opts) {
9026
10168
  agent: opts.agent,
9027
10169
  addDirs: listFromRepeatedOpts(opts.addDir),
9028
10170
  timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
10171
+ verifierIdleTimeoutMs: idleTimeoutDuration(opts.verifierIdleTimeout, "--verifier-idle-timeout"),
9029
10172
  permissionMode,
9030
10173
  sandbox,
9031
10174
  manualBreakGlass: Boolean(opts.manualBreakGlass),
@@ -9041,6 +10184,7 @@ function routeGenericEvent(event, opts) {
9041
10184
  event: event.id
9042
10185
  });
9043
10186
  const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
10187
+ const hasExplicitRoleAccount = Boolean(opts.workerAuthProfile || opts.verifierAuthProfile || opts.workerAccount || opts.verifierAccount);
9044
10188
  const invocationInput = {
9045
10189
  templateId: "event-worker-verifier",
9046
10190
  sourceRef: {
@@ -9062,7 +10206,8 @@ function routeGenericEvent(event, opts) {
9062
10206
  worktreePolicy: opts.worktreeMode ?? "auto",
9063
10207
  permissions: permissionMode,
9064
10208
  manualBreakGlass: Boolean(opts.manualBreakGlass),
9065
- accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
10209
+ accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
10210
+ providerRouting: providerRoutingPublic(providerRouting),
9066
10211
  concurrencyGroup: projectGroup ?? routeProjectPath
9067
10212
  },
9068
10213
  outputPolicy: {
@@ -9101,7 +10246,7 @@ function routeGenericEvent(event, opts) {
9101
10246
  }, {}) : undefined;
9102
10247
  return {
9103
10248
  kind: "created",
9104
- value: { event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
10249
+ value: { event, idempotencyKey, providerRouting: providerRoutingPublic(providerRouting), invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
9105
10250
  human: `dry-run ${loopName}`
9106
10251
  };
9107
10252
  }
@@ -9117,8 +10262,8 @@ function routeGenericEvent(event, opts) {
9117
10262
  const outcome = store.writeTransaction(() => {
9118
10263
  const invocation = store.createWorkflowInvocation(invocationInput);
9119
10264
  const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
9120
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
9121
- const existingLoop = store.getLoop(existingItem.loopId);
10265
+ if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
10266
+ const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
9122
10267
  const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
9123
10268
  return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
9124
10269
  }
@@ -9129,6 +10274,11 @@ function routeGenericEvent(event, opts) {
9129
10274
  status: throttle && !throttle.allowed ? "deferred" : "queued",
9130
10275
  lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
9131
10276
  });
10277
+ const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
10278
+ previousWorkItemId: workItem.id,
10279
+ previousAttempts: workItem.attempts,
10280
+ reason: workItem.lastReason
10281
+ } : undefined;
9132
10282
  const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
9133
10283
  if (throttle && !throttle.allowed)
9134
10284
  return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
@@ -9145,7 +10295,15 @@ function routeGenericEvent(event, opts) {
9145
10295
  }
9146
10296
  });
9147
10297
  const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
9148
- return { kind: "created", invocation: refreshedInvocation, workItem: admitted, workflow, loop, throttle };
10298
+ return {
10299
+ kind: "created",
10300
+ invocation: refreshedInvocation,
10301
+ workItem: admitted,
10302
+ workflow,
10303
+ loop,
10304
+ throttle,
10305
+ requeue: requeue ? { ...requeue, attempt: admitted.attempts, newWorkflowId: workflow.id, newLoopId: loop.id } : undefined
10306
+ };
9149
10307
  });
9150
10308
  if (outcome.kind === "deduped") {
9151
10309
  return {
@@ -9155,6 +10313,7 @@ function routeGenericEvent(event, opts) {
9155
10313
  idempotencyKey,
9156
10314
  dedupedBy: "work-item",
9157
10315
  event,
10316
+ providerRouting: providerRoutingPublic(providerRouting),
9158
10317
  invocation: publicWorkflowInvocation(outcome.invocation),
9159
10318
  workItem: publicWorkflowWorkItem(outcome.existingItem),
9160
10319
  workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
@@ -9172,6 +10331,7 @@ function routeGenericEvent(event, opts) {
9172
10331
  reason: outcome.throttle.reason,
9173
10332
  idempotencyKey,
9174
10333
  event,
10334
+ providerRouting: providerRoutingPublic(providerRouting),
9175
10335
  invocation: publicWorkflowInvocation(outcome.invocation),
9176
10336
  workItem: publicWorkflowWorkItem(outcome.workItem),
9177
10337
  throttle: outcome.throttle,
@@ -9187,10 +10347,12 @@ function routeGenericEvent(event, opts) {
9187
10347
  deduped: false,
9188
10348
  idempotencyKey,
9189
10349
  event,
10350
+ providerRouting: providerRoutingPublic(providerRouting),
9190
10351
  invocation: publicWorkflowInvocation(outcome.invocation),
9191
10352
  workItem: publicWorkflowWorkItem(outcome.workItem),
9192
10353
  workflow: publicWorkflow(outcome.workflow),
9193
10354
  loop: publicLoop(outcome.loop),
10355
+ requeue: outcome.requeue,
9194
10356
  throttle: outcome.throttle,
9195
10357
  sandboxPreflight,
9196
10358
  preflight
@@ -9221,14 +10383,14 @@ function taskDescriptionProjectPath(task) {
9221
10383
  return match?.[1]?.trim();
9222
10384
  }
9223
10385
  function taskProjectPath(task) {
9224
- const metadata = objectField(task.metadata) ?? {};
10386
+ const metadata = objectField2(task.metadata) ?? {};
9225
10387
  return taskField(task, ["project_path", "projectPath"]) ?? taskEventField(metadata, ["project_path", "projectPath", "project_canonical_path"]) ?? taskDescriptionProjectPath(task) ?? taskField(task, ["working_dir", "workingDir", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "cwd"]);
9226
10388
  }
9227
10389
  function taskDrainEvent(task) {
9228
10390
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
9229
10391
  if (!taskId)
9230
10392
  throw new Error("todos ready returned a task without an id");
9231
- const metadata = objectField(task.metadata) ?? {};
10393
+ const metadata = objectField2(task.metadata) ?? {};
9232
10394
  const workingDir = taskProjectPath(task);
9233
10395
  const data = {
9234
10396
  ...task,
@@ -9236,7 +10398,7 @@ function taskDrainEvent(task) {
9236
10398
  title: taskField(task, ["title"]),
9237
10399
  description: taskField(task, ["description", "body"]),
9238
10400
  status: taskField(task, ["status"]),
9239
- tags: tagsFromValue(task.tags),
10401
+ tags: tagsFromValue2(task.tags),
9240
10402
  metadata
9241
10403
  };
9242
10404
  if (workingDir) {
@@ -9264,10 +10426,12 @@ function taskDrainEvent(task) {
9264
10426
  }
9265
10427
  function compactDrainResult(result) {
9266
10428
  const value = result.value;
9267
- const event = objectField(value.event);
9268
- const loop = objectField(value.loop);
9269
- const workflow = objectField(value.workflow);
9270
- const throttle = objectField(value.throttle);
10429
+ const event = objectField2(value.event);
10430
+ const loop = objectField2(value.loop);
10431
+ const workflow = objectField2(value.workflow);
10432
+ const throttle = objectField2(value.throttle);
10433
+ const requeue = objectField2(value.requeue);
10434
+ const providerRouting = objectField2(value.providerRouting);
9271
10435
  return {
9272
10436
  kind: result.kind,
9273
10437
  taskId: event?.subject,
@@ -9278,6 +10442,8 @@ function compactDrainResult(result) {
9278
10442
  loopName: stringField(loop?.name),
9279
10443
  workflowId: stringField(workflow?.id),
9280
10444
  workflowName: stringField(workflow?.name),
10445
+ providerRouting,
10446
+ requeue,
9281
10447
  queuedAtSource: value.queuedAtSource
9282
10448
  };
9283
10449
  }
@@ -9324,7 +10490,7 @@ function taskMatchesDrainFilters(task, filters) {
9324
10490
  return false;
9325
10491
  }
9326
10492
  if (filters.tags.length) {
9327
- const taskTags = new Set(tagsFromValue(task.tags));
10493
+ const taskTags = new Set(tagsFromValue2(task.tags));
9328
10494
  for (const tag of filters.tags) {
9329
10495
  if (!taskTags.has(tag))
9330
10496
  return false;
@@ -9456,10 +10622,10 @@ var events = program.command("events").description("handle Hasna event envelopes
9456
10622
  var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
9457
10623
  var goal = program.command("goal").description("inspect goal runs");
9458
10624
  function addRouteEventOptions(command) {
9459
- return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
10625
+ return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider; defaults to codewith").option("--provider-rule <rule>", "task metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--pr-handoff", "for task-lifecycle routes, add a bounded PR handoff step after the worker").option("--github-reviewer <login>", "GitHub login expected to review/merge review-required PR routes; must differ from PR author").option("--github-reviewer-pool <logins>", "comma-separated GitHub logins eligible to review/merge review-required PR routes").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
9460
10626
  }
9461
10627
  function addTodosDrainOptions(command, opts = {}) {
9462
- let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", opts.preflightDescription ?? "check generated workflow steps before storing workflow loops");
10628
+ let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider; defaults to codewith").option("--provider-rule <rule>", "task metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--pr-handoff", "for task-lifecycle routes, add a bounded PR handoff step after the worker").option("--github-reviewer <login>", "GitHub login expected to review/merge review-required PR routes; must differ from PR author").option("--github-reviewer-pool <logins>", "comma-separated GitHub logins eligible to review/merge review-required PR routes").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", opts.preflightDescription ?? "check generated workflow steps before storing workflow loops");
9463
10629
  if (opts.includeDryRun ?? true) {
9464
10630
  configured = configured.option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
9465
10631
  }
@@ -9494,6 +10660,8 @@ function routeDrainArgs(opts) {
9494
10660
  addBool("--compact", opts.compact);
9495
10661
  add("--template", opts.template);
9496
10662
  add("--provider", opts.provider);
10663
+ for (const rule of opts.providerRule ?? [])
10664
+ add("--provider-rule", rule);
9497
10665
  add("--auth-profile", opts.authProfile);
9498
10666
  add("--auth-profile-pool", opts.authProfilePool);
9499
10667
  add("--triage-auth-profile", opts.triageAuthProfile);
@@ -9513,6 +10681,7 @@ function routeDrainArgs(opts) {
9513
10681
  for (const dir of listFromRepeatedOpts(opts.addDir) ?? [])
9514
10682
  add("--add-dir", dir);
9515
10683
  add("--timeout", opts.timeout);
10684
+ add("--verifier-idle-timeout", opts.verifierIdleTimeout);
9516
10685
  add("--permission-mode", opts.permissionMode);
9517
10686
  add("--sandbox", opts.sandbox);
9518
10687
  addBool("--manual-break-glass", opts.manualBreakGlass);
@@ -9524,6 +10693,9 @@ function routeDrainArgs(opts) {
9524
10693
  add("--worktree-mode", opts.worktreeMode);
9525
10694
  add("--worktree-root", opts.worktreeRoot);
9526
10695
  add("--worktree-branch-prefix", opts.worktreeBranchPrefix);
10696
+ addBool("--pr-handoff", opts.prHandoff);
10697
+ add("--github-reviewer", opts.githubReviewer);
10698
+ add("--github-reviewer-pool", opts.githubReviewerPool);
9527
10699
  add("--name-prefix", opts.namePrefix);
9528
10700
  addBool("--preflight", opts.preflight);
9529
10701
  return args;
@@ -9559,7 +10731,8 @@ function drainTodosTaskRoutes(opts) {
9559
10731
  const message = error instanceof Error ? error.message : String(error);
9560
10732
  if (!isSkippableDrainRouteError(message))
9561
10733
  throw error;
9562
- result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed");
10734
+ const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
10735
+ result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
9563
10736
  }
9564
10737
  results.push(result);
9565
10738
  if (result.kind === "created" && !opts.dryRun)
@@ -9770,6 +10943,18 @@ routes.command("show <id>").description("show one admission work item").action((
9770
10943
  store.close();
9771
10944
  }
9772
10945
  });
10946
+ routes.command("requeue <id>").description("requeue a terminal admission work item for the next task/event delivery").option("--reason <text>", "operator reason recorded on the work item").action((id, opts) => {
10947
+ const store = new Store;
10948
+ try {
10949
+ const reason = stringField(opts.reason);
10950
+ if (!reason)
10951
+ throw new Error("routes requeue requires --reason <text>");
10952
+ const item = store.requeueWorkflowWorkItem(id, { reason });
10953
+ print(publicWorkflowWorkItem(item), `requeued route work item ${item.id} (${item.routeKey})`);
10954
+ } finally {
10955
+ store.close();
10956
+ }
10957
+ });
9773
10958
  routes.command("invocations").description("list workflow invocations").option("--limit <n>", "maximum rows", "50").action((opts) => {
9774
10959
  const store = new Store;
9775
10960
  try {
@@ -9824,7 +11009,7 @@ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>")
9824
11009
  }
9825
11010
  });
9826
11011
  var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
9827
- eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
11012
+ eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider; defaults to codewith").option("--provider-rule <rule>", "task metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--github-reviewer <login>", "GitHub login expected to review/merge review-required PR routes; must differ from PR author").option("--github-reviewer-pool <logins>", "comma-separated GitHub logins eligible to review/merge review-required PR routes").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
9828
11013
  const event = await readEventEnvelopeFromStdin();
9829
11014
  const result = routeTodosTaskEvent(event, opts);
9830
11015
  print(result.value, result.human);
@@ -9833,7 +11018,7 @@ var eventsDrain = events.command("drain").description("drain durable source queu
9833
11018
  addTodosDrainOptions(eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")).action((opts) => {
9834
11019
  drainTodosTaskRoutes(opts);
9835
11020
  });
9836
- eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
11021
+ eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--provider-rule <rule>", "event metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
9837
11022
  const event = await readEventEnvelopeFromStdin();
9838
11023
  const result = routeGenericEvent(event, opts);
9839
11024
  print(result.value, result.human);
@@ -10118,6 +11303,66 @@ workflows.command("migrate-agent-timeouts").description("append-only migrate act
10118
11303
  store.close();
10119
11304
  }
10120
11305
  });
11306
+ workflows.command("migrate-goal-wrappers").description("append-only migrate active workflow loops away from workflow-level goal wrappers").option("--loop <idOrName>", "migrate only one loop instead of all active workflow loops").option("--apply", "create new workflow specs and retarget eligible loops").option("--archive-old", "archive old workflow specs after retargeting when no active loops still reference them").action((opts) => {
11307
+ const store = new Store;
11308
+ try {
11309
+ const candidateLoops = opts.loop ? [store.requireUniqueLoop(opts.loop)] : store.listLoops({ status: "active", limit: 1e4 }).filter((loop) => loop.target.type === "workflow");
11310
+ const rows = [];
11311
+ for (const loop of candidateLoops) {
11312
+ if (loop.archivedAt) {
11313
+ rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is archived" });
11314
+ continue;
11315
+ }
11316
+ if (loop.target.type !== "workflow") {
11317
+ rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is not a workflow loop" });
11318
+ continue;
11319
+ }
11320
+ const workflow = store.requireWorkflow(loop.target.workflowId);
11321
+ const nextWorkflowName = workflowGoalWrapperMigrationName(workflow);
11322
+ const migration = workflowWithoutGoalWrapper(workflow, { name: nextWorkflowName });
11323
+ if (!migration.changed) {
11324
+ rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "skipped", reason: "workflow has no top-level goal wrapper" });
11325
+ continue;
11326
+ }
11327
+ if (store.hasRunningRun(loop.id)) {
11328
+ rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "blocked", reason: "loop has a running run; retry after it finishes" });
11329
+ continue;
11330
+ }
11331
+ if (!opts.apply) {
11332
+ rows.push({
11333
+ loop: publicLoop(loop),
11334
+ workflow: publicWorkflow(workflow),
11335
+ status: "would_migrate",
11336
+ nextWorkflowName,
11337
+ removedGoal: workflow.goal
11338
+ });
11339
+ continue;
11340
+ }
11341
+ const migrated = store.createAndRetargetWorkflowLoop(loop.id, migration.body, {
11342
+ workflowTimeoutMs: loop.target.timeoutMs,
11343
+ archiveOld: Boolean(opts.archiveOld)
11344
+ });
11345
+ rows.push({
11346
+ loop: publicLoop(migrated.loop),
11347
+ previousWorkflow: publicWorkflow(migrated.previousWorkflow),
11348
+ workflow: publicWorkflow(migrated.workflow),
11349
+ archivedOld: migrated.archivedOld ? publicWorkflow(migrated.archivedOld) : undefined,
11350
+ status: "migrated"
11351
+ });
11352
+ }
11353
+ const summary = {
11354
+ apply: Boolean(opts.apply),
11355
+ total: rows.length,
11356
+ migrated: rows.filter((row) => row.status === "migrated").length,
11357
+ wouldMigrate: rows.filter((row) => row.status === "would_migrate").length,
11358
+ blocked: rows.filter((row) => row.status === "blocked").length,
11359
+ skipped: rows.filter((row) => row.status === "skipped").length
11360
+ };
11361
+ print({ summary, rows }, opts.apply ? `migrated=${summary.migrated} blocked=${summary.blocked} skipped=${summary.skipped}` : `would_migrate=${summary.wouldMigrate} blocked=${summary.blocked} skipped=${summary.skipped}`);
11362
+ } finally {
11363
+ store.close();
11364
+ }
11365
+ });
10121
11366
  workflows.command("archive <idOrName>").action((idOrName) => {
10122
11367
  const store = new Store;
10123
11368
  try {
@@ -10139,7 +11384,7 @@ program.command("list").alias("ls").option("--status <status>", "filter by statu
10139
11384
  for (const loop of loops) {
10140
11385
  const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
10141
11386
  const archive = loop.archivedAt ? ` archived=${loop.archivedAt} from=${loop.archivedFromStatus ?? "-"}` : "";
10142
- console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
11387
+ console.log(`${loop.id} ${loop.status.padEnd(7)} cadence=${scheduleLabel(loop.schedule)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
10143
11388
  }
10144
11389
  }
10145
11390
  } finally {
@@ -10779,11 +12024,11 @@ ${result.instructions.join(`
10779
12024
  });
10780
12025
  daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
10781
12026
  const path = daemonLogPath();
10782
- if (!existsSync5(path)) {
12027
+ if (!existsSync6(path)) {
10783
12028
  console.log("");
10784
12029
  return;
10785
12030
  }
10786
- const lines = readFileSync4(path, "utf8").trimEnd().split(`
12031
+ const lines = readFileSync5(path, "utf8").trimEnd().split(`
10787
12032
  `);
10788
12033
  console.log(lines.slice(-Number(opts.lines)).join(`
10789
12034
  `));