@hasna/loops 0.3.56 → 0.3.57

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)
@@ -1763,7 +1766,7 @@ class Store {
1763
1766
  if (!sourceDedupeKey)
1764
1767
  throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1765
1768
  const now = nowIso();
1766
- const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1769
+ const claimableStatuses = ["queued", "deferred"];
1767
1770
  const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1768
1771
  const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1769
1772
  const result = this.db.query(`UPDATE workflow_invocations
@@ -1844,28 +1847,35 @@ class Store {
1844
1847
  project_group=excluded.project_group,
1845
1848
  priority=excluded.priority,
1846
1849
  status=CASE
1847
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1850
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
1848
1851
  THEN workflow_work_items.status
1849
1852
  ELSE excluded.status
1850
1853
  END,
1851
1854
  workflow_id=CASE
1852
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1855
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
1853
1856
  ELSE NULL
1854
1857
  END,
1855
1858
  loop_id=CASE
1856
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1859
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
1857
1860
  ELSE NULL
1858
1861
  END,
1859
1862
  workflow_run_id=CASE
1860
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1863
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
1861
1864
  ELSE NULL
1862
1865
  END,
1863
1866
  lease_expires_at=CASE
1864
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1867
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
1865
1868
  ELSE NULL
1866
1869
  END,
1867
1870
  next_attempt_at=excluded.next_attempt_at,
1868
- last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1871
+ last_reason=CASE
1872
+ WHEN workflow_work_items.attempts > 0
1873
+ AND workflow_work_items.status IN ('queued', 'deferred')
1874
+ AND workflow_work_items.last_reason IS NOT NULL
1875
+ AND excluded.last_reason IS NOT NULL
1876
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
1877
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
1878
+ END,
1869
1879
  updated_at=excluded.updated_at`).run({
1870
1880
  $id: id,
1871
1881
  $routeKey: input.routeKey,
@@ -1918,11 +1928,39 @@ class Store {
1918
1928
  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
1929
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1920
1930
  }
1931
+ requeueWorkflowWorkItem(id, patch = {}) {
1932
+ const current = this.getWorkflowWorkItem(id);
1933
+ if (!current)
1934
+ throw new Error(`workflow work item not found: ${id}`);
1935
+ const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
1936
+ if (!requeueableStatuses.includes(current.status)) {
1937
+ throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
1938
+ }
1939
+ const now = nowIso();
1940
+ const reason = patch.reason?.trim() || `requeued from ${current.status}`;
1941
+ const placeholders = requeueableStatuses.map(() => "?").join(",");
1942
+ const res = this.db.query(`UPDATE workflow_work_items
1943
+ SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
1944
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
1945
+ WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
1946
+ const item = this.getWorkflowWorkItem(id);
1947
+ if (!item)
1948
+ throw new Error(`workflow work item not found after requeue: ${id}`);
1949
+ if (res.changes !== 1)
1950
+ throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
1951
+ return item;
1952
+ }
1921
1953
  admitWorkflowWorkItem(id, patch) {
1922
1954
  const now = nowIso();
1923
1955
  const res = this.db.query(`UPDATE workflow_work_items
1924
1956
  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
1957
+ next_attempt_at=NULL,
1958
+ lease_expires_at=NULL,
1959
+ last_reason=CASE
1960
+ WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
1961
+ ELSE COALESCE($reason, last_reason)
1962
+ END,
1963
+ updated_at=$updated
1926
1964
  WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1927
1965
  $id: id,
1928
1966
  $workflowId: patch.workflowId,
@@ -3105,7 +3143,7 @@ class Store {
3105
3143
 
3106
3144
  // src/cli/index.ts
3107
3145
  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";
3146
+ 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
3147
  import { spawnSync as spawnSync5 } from "child_process";
3110
3148
  import { dirname as dirname4, join as join6, resolve as resolve3 } from "path";
3111
3149
  import { tmpdir as tmpdir2 } from "os";
@@ -3580,6 +3618,9 @@ function assertSupportedAgentOptions(target) {
3580
3618
  assertStringOption(target.agent, `${target.provider}.agent`);
3581
3619
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3582
3620
  assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3621
+ if (target.provider === "opencode" && (target.model === undefined || target.model.trim() === "")) {
3622
+ throw new Error("opencode.model is required; pass a provider/model id such as openrouter/google/gemini-2.5-flash");
3623
+ }
3583
3624
  if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3584
3625
  throw new Error(`${target.provider}.configIsolation must be safe or none`);
3585
3626
  }
@@ -4352,6 +4393,78 @@ function sameBlockerKey(values) {
4352
4393
  return values.map((value) => value.trim()).filter(Boolean).join(`
4353
4394
  `) || "goal completion remains unproven";
4354
4395
  }
4396
+ function planStatusForGoal(goal) {
4397
+ if (goal.status === "usageLimited")
4398
+ return "blocked";
4399
+ return goal.status;
4400
+ }
4401
+ function syncReadyFlags(store, goal, nodes, opts) {
4402
+ const withReady = updateReadyFlags(nodes, planStatusForGoal(goal));
4403
+ for (const node of withReady) {
4404
+ const current = nodes.find((entry) => entry.key === node.key);
4405
+ if (current && current.ready !== node.ready) {
4406
+ store.updateGoalPlanNode(goal.goalId, node.key, { ready: node.ready }, { daemonLeaseId: opts.daemonLeaseId });
4407
+ }
4408
+ }
4409
+ return withReady;
4410
+ }
4411
+ function summarizeLabels(values, limit = 5) {
4412
+ if (values.length <= limit)
4413
+ return values.join(", ");
4414
+ return `${values.slice(0, limit).join(", ")} and ${values.length - limit} more`;
4415
+ }
4416
+ function noReadyDiagnostic(goal, nodes) {
4417
+ const planStatus = planStatusForGoal(goal);
4418
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
4419
+ const pendingNodes = nodes.filter((node) => node.status === "pending");
4420
+ const incompleteNodes = nodes.filter((node) => node.status !== "complete");
4421
+ const pendingDetails = pendingNodes.map((node) => ({
4422
+ key: node.key,
4423
+ status: node.status,
4424
+ ready: node.ready,
4425
+ tokenBudget: node.tokenBudget,
4426
+ tokensUsed: node.tokensUsed,
4427
+ budgetExhausted: nodeBudgetExhausted(node),
4428
+ unmetDependencies: node.dependsOn.map((dependency) => ({ key: dependency, status: byKey.get(dependency)?.status ?? "missing" })).filter((dependency) => dependency.status !== "complete")
4429
+ }));
4430
+ const incompleteDetails = incompleteNodes.map((node) => ({
4431
+ key: node.key,
4432
+ status: node.status,
4433
+ ready: node.ready
4434
+ }));
4435
+ let owner = "goal-plan";
4436
+ let cause = "goal plan has no ready nodes";
4437
+ if (planStatus !== "active") {
4438
+ owner = "goal";
4439
+ cause = `goal status is ${goal.status}`;
4440
+ } else if (pendingNodes.length === 0) {
4441
+ owner = "goal-plan";
4442
+ cause = `goal plan has no pending runnable nodes; incomplete nodes: ${summarizeLabels(incompleteDetails.map((node) => `${node.key}:${node.status}`))}`;
4443
+ } else if (pendingDetails.every((node) => node.budgetExhausted)) {
4444
+ owner = "goal-plan-node";
4445
+ cause = `all pending nodes are budget-exhausted: ${summarizeLabels(pendingDetails.map((node) => node.key))}`;
4446
+ } else {
4447
+ const missingDependencies = pendingDetails.flatMap((node) => node.unmetDependencies.filter((dependency) => dependency.status === "missing").map((dependency) => `${node.key}->${dependency.key}`));
4448
+ if (missingDependencies.length > 0) {
4449
+ owner = "goal-plan";
4450
+ cause = `pending nodes reference missing dependencies: ${summarizeLabels(missingDependencies)}`;
4451
+ } else if (pendingDetails.every((node) => node.unmetDependencies.length > 0 || node.budgetExhausted)) {
4452
+ owner = "goal-plan-node";
4453
+ const waiting = pendingDetails.filter((node) => node.unmetDependencies.length > 0).map((node) => `${node.key} waits on ${node.unmetDependencies.map((dependency) => `${dependency.key}:${dependency.status}`).join(",")}`);
4454
+ const exhausted = pendingDetails.filter((node) => node.budgetExhausted).map((node) => `${node.key} budget exhausted`);
4455
+ cause = `pending nodes are blocked by prerequisites: ${summarizeLabels([...waiting, ...exhausted])}`;
4456
+ }
4457
+ }
4458
+ const blocker = `${cause}; owner=${owner}`;
4459
+ return {
4460
+ owner,
4461
+ blocker,
4462
+ planStatus,
4463
+ rollup: rollupSummary(nodes),
4464
+ pendingNodes: pendingDetails,
4465
+ incompleteNodes: incompleteDetails
4466
+ };
4467
+ }
4355
4468
  function metadataFor(goal, node, context) {
4356
4469
  return {
4357
4470
  loopId: context?.loopId,
@@ -4412,13 +4525,14 @@ async function planGoal(store, goal, spec, model, opts) {
4412
4525
  }, { daemonLeaseId: opts.daemonLeaseId });
4413
4526
  return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
4414
4527
  }
4415
- function stdoutFor(goal, nodes, evidence, validation) {
4528
+ function stdoutFor(goal, nodes, evidence, validation, diagnostics) {
4416
4529
  return JSON.stringify({
4417
4530
  goal,
4418
4531
  rollup: rollupSummary(nodes),
4419
4532
  nodes,
4420
4533
  evidence,
4421
- validation
4534
+ validation,
4535
+ diagnostics
4422
4536
  }, null, 2);
4423
4537
  }
4424
4538
  async function runGoal(store, input, opts = {}) {
@@ -4451,6 +4565,7 @@ async function runGoal(store, input, opts = {}) {
4451
4565
  let validation;
4452
4566
  let lastBlocker = "";
4453
4567
  let repeatedBlockerCount = 0;
4568
+ let lastDiagnostic;
4454
4569
  if (budgetExhausted(goal)) {
4455
4570
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4456
4571
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
@@ -4461,13 +4576,13 @@ async function runGoal(store, input, opts = {}) {
4461
4576
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
4462
4577
  }
4463
4578
  goal = store.requireGoal(goal.goalId);
4464
- nodes = store.listGoalPlanNodes(goal.goalId);
4579
+ nodes = syncReadyFlags(store, goal, store.listGoalPlanNodes(goal.goalId), opts);
4465
4580
  if (budgetExhausted(goal)) {
4466
4581
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4467
4582
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
4468
4583
  }
4469
4584
  const readyKeys = readyNodeKeys({
4470
- status: goal.status === "active" ? "active" : goal.status === "budgetLimited" ? "budgetLimited" : "blocked",
4585
+ status: planStatusForGoal(goal),
4471
4586
  nodes
4472
4587
  });
4473
4588
  if (readyKeys.length > 0) {
@@ -4566,7 +4681,9 @@ ${result.stderr}`);
4566
4681
  }
4567
4682
  continue;
4568
4683
  }
4569
- const blocker = "no ready goal nodes and goal plan is incomplete";
4684
+ const diagnostic = noReadyDiagnostic(goal, nodes);
4685
+ lastDiagnostic = diagnostic;
4686
+ const blocker = diagnostic.blocker;
4570
4687
  if (blocker === lastBlocker)
4571
4688
  repeatedBlockerCount += 1;
4572
4689
  else {
@@ -4578,15 +4695,16 @@ ${result.stderr}`);
4578
4695
  turn,
4579
4696
  phase: "status",
4580
4697
  status: repeatedBlockerCount >= 3 ? "blocked" : "active",
4581
- evidence: { blocker }
4698
+ evidence: diagnostic
4582
4699
  }, { daemonLeaseId: opts.daemonLeaseId });
4583
4700
  if (repeatedBlockerCount >= 3) {
4584
4701
  goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
4585
- return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
4702
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation, diagnostic), blocker, startedAt);
4586
4703
  }
4587
4704
  }
4588
4705
  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);
4706
+ const exhaustedError = lastDiagnostic?.blocker ?? (lastBlocker ? `${lastBlocker}; max turns exhausted` : "goal max turns exhausted");
4707
+ return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation, lastDiagnostic), exhaustedError, startedAt);
4590
4708
  }
4591
4709
 
4592
4710
  // src/lib/workflow-runner.ts
@@ -5708,6 +5826,7 @@ function runDoctor(store) {
5708
5826
 
5709
5827
  // src/lib/health.ts
5710
5828
  import { createHash as createHash2 } from "crypto";
5829
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
5711
5830
  var EVIDENCE_CHARS = 2000;
5712
5831
  var FINGERPRINT_EVIDENCE_CHARS = 120;
5713
5832
  var CLASSIFICATIONS = [
@@ -5718,6 +5837,7 @@ var CLASSIFICATIONS = [
5718
5837
  "schema_response_format",
5719
5838
  "node_init",
5720
5839
  "preflight",
5840
+ "route_functional",
5721
5841
  "timeout",
5722
5842
  "sigsegv",
5723
5843
  "skipped_previous_active",
@@ -5738,6 +5858,25 @@ function searchableText(run) {
5738
5858
  return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
5739
5859
  `).toLowerCase();
5740
5860
  }
5861
+ function isRecord(value) {
5862
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
5863
+ }
5864
+ function stringValue(value) {
5865
+ return typeof value === "string" && value.trim() ? value : undefined;
5866
+ }
5867
+ function objectField(value, key) {
5868
+ if (!value)
5869
+ return;
5870
+ const field = value[key];
5871
+ return isRecord(field) ? field : undefined;
5872
+ }
5873
+ function tagsFromValue(value) {
5874
+ if (Array.isArray(value))
5875
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
5876
+ if (typeof value === "string")
5877
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
5878
+ return [];
5879
+ }
5741
5880
  function stableFingerprint(parts) {
5742
5881
  return createHash2("sha256").update(parts.join(`
5743
5882
  `)).digest("hex").slice(0, 16);
@@ -5751,6 +5890,13 @@ function stableFailureFingerprint(run, classification) {
5751
5890
  (run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
5752
5891
  ]);
5753
5892
  }
5893
+ function stableRouteFunctionalFingerprint(loop, reason) {
5894
+ return stableFingerprint([
5895
+ loop.id,
5896
+ "route_functional",
5897
+ reason.replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
5898
+ ]);
5899
+ }
5754
5900
  function healthRun(run) {
5755
5901
  return {
5756
5902
  ...run,
@@ -5795,6 +5941,148 @@ function classifyRunFailure(run) {
5795
5941
  }
5796
5942
  };
5797
5943
  }
5944
+ var ROUTE_FUNCTIONAL_DISALLOWED_TAGS = new Set([
5945
+ "no-auto",
5946
+ "manual",
5947
+ "manual-required",
5948
+ "approval-required",
5949
+ "blocked",
5950
+ "completed",
5951
+ "done",
5952
+ "cancelled",
5953
+ "canceled",
5954
+ "failed",
5955
+ "archived"
5956
+ ]);
5957
+ var ROUTE_FUNCTIONAL_DISALLOWED_STATUSES = new Set([
5958
+ "blocked",
5959
+ "completed",
5960
+ "done",
5961
+ "cancelled",
5962
+ "canceled",
5963
+ "failed",
5964
+ "archived"
5965
+ ]);
5966
+ function parseJsonObject(raw) {
5967
+ if (!raw?.trim())
5968
+ return;
5969
+ try {
5970
+ const parsed = JSON.parse(raw);
5971
+ return isRecord(parsed) ? parsed : undefined;
5972
+ } catch {
5973
+ return;
5974
+ }
5975
+ }
5976
+ function routeEvidenceReport(run) {
5977
+ const stdoutReport = parseJsonObject(run.stdout);
5978
+ const evidencePath = stringValue(stdoutReport?.evidencePath);
5979
+ if (evidencePath && existsSync4(evidencePath)) {
5980
+ try {
5981
+ return parseJsonObject(readFileSync3(evidencePath, "utf8")) ?? stdoutReport;
5982
+ } catch {
5983
+ return stdoutReport;
5984
+ }
5985
+ }
5986
+ return stdoutReport;
5987
+ }
5988
+ function commandName(command) {
5989
+ return command.split(/[\\/]/).at(-1) ?? command;
5990
+ }
5991
+ function argsContainSequence(args, sequence) {
5992
+ for (let index = 0;index <= args.length - sequence.length; index += 1) {
5993
+ if (sequence.every((part, offset) => args[index + offset] === part))
5994
+ return true;
5995
+ }
5996
+ return false;
5997
+ }
5998
+ function isRouteDrainLoop(loop) {
5999
+ if (loop.target.type !== "command")
6000
+ return false;
6001
+ if (commandName(loop.target.command) !== "loops")
6002
+ return false;
6003
+ const args = loop.target.args ?? [];
6004
+ return argsContainSequence(args, ["events", "drain", "todos-task"]) || argsContainSequence(args, ["routes", "drain", "todos-task"]) || argsContainSequence(args, ["route", "drain", "todos-task"]);
6005
+ }
6006
+ function routeResultTaskState(result) {
6007
+ const event = objectField(result, "event");
6008
+ const data = objectField(event, "data");
6009
+ const task = objectField(data, "task");
6010
+ const payload = objectField(data, "payload");
6011
+ const payloadTask = objectField(payload, "task");
6012
+ const metadata = objectField(data, "metadata");
6013
+ const records = [data, task, payload, payloadTask, metadata].filter(isRecord);
6014
+ const tags = new Set;
6015
+ for (const record of records) {
6016
+ for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags)) {
6017
+ tags.add(tag.toLowerCase());
6018
+ }
6019
+ }
6020
+ const status = records.map((record) => stringValue(record.status ?? record.task_status ?? record.taskStatus)?.toLowerCase()).find(Boolean);
6021
+ return {
6022
+ taskId: stringValue(event?.subject) ?? stringValue(data?.id) ?? stringValue(task?.id) ?? stringValue(payloadTask?.id),
6023
+ tags: [...tags],
6024
+ status
6025
+ };
6026
+ }
6027
+ function detectRouteFunctionalFailure(store, loop, run) {
6028
+ if (run.status !== "succeeded")
6029
+ return;
6030
+ if (!isRouteDrainLoop(loop))
6031
+ return;
6032
+ const report = routeEvidenceReport(run);
6033
+ const rawResults = Array.isArray(report?.results) ? report.results.filter(isRecord) : [];
6034
+ for (const result of rawResults) {
6035
+ const kind = stringValue(result.kind);
6036
+ const task = routeResultTaskState(result);
6037
+ const disallowedTag = task.tags.find((tag) => ROUTE_FUNCTIONAL_DISALLOWED_TAGS.has(tag));
6038
+ if (kind && kind !== "skipped" && disallowedTag) {
6039
+ const reason2 = `route drain ${kind} task ${task.taskId ?? "unknown"} with disallowed tag ${disallowedTag}`;
6040
+ return {
6041
+ classification: "route_functional",
6042
+ fingerprint: stableRouteFunctionalFingerprint(loop, reason2),
6043
+ evidence: { error: reason2, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6044
+ };
6045
+ }
6046
+ if (kind && kind !== "skipped" && task.status && ROUTE_FUNCTIONAL_DISALLOWED_STATUSES.has(task.status)) {
6047
+ const reason2 = `route drain ${kind} task ${task.taskId ?? "unknown"} with non-routable status ${task.status}`;
6048
+ return {
6049
+ classification: "route_functional",
6050
+ fingerprint: stableRouteFunctionalFingerprint(loop, reason2),
6051
+ evidence: { error: reason2, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6052
+ };
6053
+ }
6054
+ const reason = stringValue(result.reason);
6055
+ if (kind === "skipped" && reason === "task metadata requires manual or approval-gated handling") {
6056
+ const message = `route drain skipped task ${task.taskId ?? "unknown"} with ambiguous manual-gate reason`;
6057
+ return {
6058
+ classification: "route_functional",
6059
+ fingerprint: stableRouteFunctionalFingerprint(loop, message),
6060
+ evidence: { error: message, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6061
+ };
6062
+ }
6063
+ const sourceTaskUpdate = objectField(result, "sourceTaskUpdate");
6064
+ if (kind === "skipped" && sourceTaskUpdate && sourceTaskUpdate.ok === false) {
6065
+ const updateError = stringValue(sourceTaskUpdate.error);
6066
+ const message = `route drain skipped task ${task.taskId ?? "unknown"} but failed to update source task${updateError ? `: ${updateError}` : ""}`;
6067
+ return {
6068
+ classification: "route_functional",
6069
+ fingerprint: stableRouteFunctionalFingerprint(loop, message),
6070
+ evidence: { error: message, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
6071
+ };
6072
+ }
6073
+ const childLoopId = stringValue(objectField(result, "loop")?.id) ?? stringValue(result.loopId);
6074
+ const childRun = childLoopId ? store.listRuns({ loopId: childLoopId, limit: 1 })[0] : undefined;
6075
+ if (childRun && !["succeeded", "running"].includes(childRun.status)) {
6076
+ const message = `route drain ${kind ?? "handled"} task ${task.taskId ?? "unknown"} but child loop ${childLoopId} latest run is ${childRun.status}`;
6077
+ return {
6078
+ classification: "route_functional",
6079
+ fingerprint: stableRouteFunctionalFingerprint(loop, message),
6080
+ evidence: { error: message, stdout: redactedEvidence(run.stdout), stderr: redactedEvidence(childRun.stderr), exitCode: childRun.exitCode }
6081
+ };
6082
+ }
6083
+ }
6084
+ return;
6085
+ }
5798
6086
  function targetRoute(loop) {
5799
6087
  if (loop.target.type === "agent") {
5800
6088
  return {
@@ -5882,6 +6170,18 @@ function expectationForLoop(store, loop) {
5882
6170
  route
5883
6171
  };
5884
6172
  }
6173
+ const routeFailure = detectRouteFunctionalFailure(store, loop, latestRun);
6174
+ if (routeFailure) {
6175
+ return {
6176
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
6177
+ ok: false,
6178
+ check: { id: "route-functional-health", status: "fail", message: routeFailure.evidence.error ?? "route functional blocker detected" },
6179
+ latestRun: healthRun(latestRun),
6180
+ failure: routeFailure,
6181
+ route,
6182
+ recommendedTask: recommendedTask(loop, latestRun, routeFailure, route)
6183
+ };
6184
+ }
5885
6185
  if (latestRun.status === "succeeded") {
5886
6186
  return {
5887
6187
  loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
@@ -5940,6 +6240,8 @@ var PROVIDER_TOKENS = new Set([
5940
6240
  "agent"
5941
6241
  ]);
5942
6242
  var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
6243
+ var CADENCE_SUFFIX_TOKENS = new Set(["hourly", "daily", "weekly", "monthly"]);
6244
+ var CADENCE_SUFFIX_PATTERN = /^(?:every-?)?\d+(?:s|m|h|d|w)$/;
5943
6245
  function slugify(value) {
5944
6246
  return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
5945
6247
  }
@@ -5992,6 +6294,16 @@ function taskSlug(loop, scope) {
5992
6294
  if (deduped[deduped.length - 1] !== token)
5993
6295
  deduped.push(token);
5994
6296
  }
6297
+ while (deduped.length) {
6298
+ const last = deduped[deduped.length - 1];
6299
+ if (CADENCE_SUFFIX_TOKENS.has(last) || CADENCE_SUFFIX_PATTERN.test(last)) {
6300
+ deduped.pop();
6301
+ if (deduped[deduped.length - 1] === "every")
6302
+ deduped.pop();
6303
+ continue;
6304
+ }
6305
+ break;
6306
+ }
5995
6307
  return deduped.join("-") || "loop";
5996
6308
  }
5997
6309
  function canonicalName(loop) {
@@ -6165,14 +6477,15 @@ function buildScriptInventoryReport(store, opts = {}) {
6165
6477
  // package.json
6166
6478
  var package_default = {
6167
6479
  name: "@hasna/loops",
6168
- version: "0.3.56",
6480
+ version: "0.3.57",
6169
6481
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
6170
6482
  type: "module",
6171
6483
  main: "dist/index.js",
6172
6484
  types: "dist/index.d.ts",
6173
6485
  bin: {
6174
6486
  loops: "dist/cli/index.js",
6175
- "loops-daemon": "dist/daemon/index.js"
6487
+ "loops-daemon": "dist/daemon/index.js",
6488
+ "loops-mcp": "dist/mcp/index.js"
6176
6489
  },
6177
6490
  exports: {
6178
6491
  ".": {
@@ -6183,6 +6496,10 @@ var package_default = {
6183
6496
  types: "./dist/sdk/index.d.ts",
6184
6497
  import: "./dist/sdk/index.js"
6185
6498
  },
6499
+ "./mcp": {
6500
+ types: "./dist/mcp/index.d.ts",
6501
+ import: "./dist/mcp/index.js"
6502
+ },
6186
6503
  "./storage": {
6187
6504
  types: "./dist/lib/store.d.ts",
6188
6505
  import: "./dist/lib/store.js"
@@ -6195,7 +6512,7 @@ var package_default = {
6195
6512
  "LICENSE"
6196
6513
  ],
6197
6514
  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",
6515
+ 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
6516
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
6200
6517
  typecheck: "tsc --noEmit",
6201
6518
  test: "bun test",
@@ -6231,6 +6548,7 @@ var package_default = {
6231
6548
  dependencies: {
6232
6549
  "@hasna/events": "^0.1.9",
6233
6550
  "@hasna/machines": "0.0.49",
6551
+ "@modelcontextprotocol/sdk": "^1.29.0",
6234
6552
  "@openrouter/ai-sdk-provider": "2.9.1",
6235
6553
  ai: "6.0.204",
6236
6554
  commander: "^13.1.0",
@@ -6255,7 +6573,7 @@ function packageVersion() {
6255
6573
 
6256
6574
  // src/lib/templates.ts
6257
6575
  import { execFileSync } from "child_process";
6258
- import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
6576
+ import { existsSync as existsSync5, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
6259
6577
  import { homedir as homedir3 } from "os";
6260
6578
  import { basename as basename3, isAbsolute as isAbsolute2, join as join5, relative, resolve as resolve2 } from "path";
6261
6579
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
@@ -6296,7 +6614,8 @@ var TEMPLATE_SUMMARIES = [
6296
6614
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6297
6615
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6298
6616
  { 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." }
6617
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6618
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6300
6619
  ]
6301
6620
  },
6302
6621
  {
@@ -6327,7 +6646,8 @@ var TEMPLATE_SUMMARIES = [
6327
6646
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6328
6647
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6329
6648
  { 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." }
6649
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6650
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6331
6651
  ]
6332
6652
  },
6333
6653
  {
@@ -6355,7 +6675,8 @@ var TEMPLATE_SUMMARIES = [
6355
6675
  { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
6356
6676
  { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
6357
6677
  { 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." }
6678
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6679
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6359
6680
  ]
6360
6681
  },
6361
6682
  {
@@ -6374,8 +6695,10 @@ var TEMPLATE_SUMMARIES = [
6374
6695
  { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
6375
6696
  { name: "provider", default: "codewith", description: "Agent provider." },
6376
6697
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6698
+ { name: "prHandoff", default: "false", description: "Add a bounded network-enabled PR handoff task step after the worker." },
6377
6699
  { 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." }
6700
+ { name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
6701
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6379
6702
  ]
6380
6703
  },
6381
6704
  {
@@ -6390,7 +6713,8 @@ var TEMPLATE_SUMMARIES = [
6390
6713
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6391
6714
  { name: "provider", default: "codewith", description: "Agent provider." },
6392
6715
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6393
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6716
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6717
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6394
6718
  ]
6395
6719
  },
6396
6720
  {
@@ -6404,7 +6728,8 @@ var TEMPLATE_SUMMARIES = [
6404
6728
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6405
6729
  { name: "provider", default: "codewith", description: "Agent provider." },
6406
6730
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6407
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6731
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6732
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6408
6733
  ]
6409
6734
  },
6410
6735
  {
@@ -6418,7 +6743,8 @@ var TEMPLATE_SUMMARIES = [
6418
6743
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6419
6744
  { name: "provider", default: "codewith", description: "Agent provider." },
6420
6745
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6421
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6746
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6747
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6422
6748
  ]
6423
6749
  },
6424
6750
  {
@@ -6432,7 +6758,8 @@ var TEMPLATE_SUMMARIES = [
6432
6758
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6433
6759
  { name: "provider", default: "codewith", description: "Agent provider." },
6434
6760
  { 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." }
6761
+ { name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." },
6762
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6436
6763
  ]
6437
6764
  },
6438
6765
  {
@@ -6447,7 +6774,8 @@ var TEMPLATE_SUMMARIES = [
6447
6774
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
6448
6775
  { name: "provider", default: "codewith", description: "Agent provider." },
6449
6776
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
6450
- { name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
6777
+ { name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
6778
+ { name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
6451
6779
  ]
6452
6780
  },
6453
6781
  {
@@ -6481,9 +6809,25 @@ function taskLabel(input) {
6481
6809
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
6482
6810
  }
6483
6811
  var UNLIMITED_AGENT_TIMEOUT_MS = null;
6812
+ var DEFAULT_VERIFIER_IDLE_TIMEOUT_MS = 15 * 60000;
6484
6813
  function agentTimeoutMs(input) {
6485
6814
  return input.timeoutMs === undefined ? UNLIMITED_AGENT_TIMEOUT_MS : input.timeoutMs;
6486
6815
  }
6816
+ function verifierIdleTimeoutMs(input) {
6817
+ if (input.verifierIdleTimeoutMs === undefined)
6818
+ return DEFAULT_VERIFIER_IDLE_TIMEOUT_MS;
6819
+ return input.verifierIdleTimeoutMs > 0 ? input.verifierIdleTimeoutMs : undefined;
6820
+ }
6821
+ function verifierRuntimeGuidance(input) {
6822
+ const idleTimeout = verifierIdleTimeoutMs(input);
6823
+ return [
6824
+ "Verifier runtime contract:",
6825
+ 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.",
6826
+ "- Keep final evidence compact: summarize changed files, validation commands/results, findings, and the task decision instead of pasting bulky logs.",
6827
+ "- If validation cannot finish, record a clear blocked/failed task comment with the last completed check and the next concrete action."
6828
+ ].join(`
6829
+ `);
6830
+ }
6487
6831
  function parseTemplateTimeoutMs(raw) {
6488
6832
  if (raw === undefined || raw.trim() === "")
6489
6833
  return;
@@ -6496,6 +6840,18 @@ function parseTemplateTimeoutMs(raw) {
6496
6840
  }
6497
6841
  return value;
6498
6842
  }
6843
+ function parseTemplateIdleTimeoutMs(raw) {
6844
+ if (raw === undefined || raw.trim() === "")
6845
+ return;
6846
+ const normalized = raw.trim().toLowerCase();
6847
+ if (["unlimited", "none", "null", "never", "off", "false"].includes(normalized))
6848
+ return 0;
6849
+ const value = Number(raw);
6850
+ if (!Number.isInteger(value) || value <= 0) {
6851
+ throw new Error("verifierIdleTimeoutMs must be a positive integer number of milliseconds, or none/off");
6852
+ }
6853
+ return value;
6854
+ }
6499
6855
  function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
6500
6856
  if (raw === undefined || raw.trim() === "")
6501
6857
  return fallbackMs;
@@ -6556,6 +6912,179 @@ function stableHex(seed) {
6556
6912
  function shellQuote2(value) {
6557
6913
  return `'${value.replace(/'/g, `'\\''`)}'`;
6558
6914
  }
6915
+ function prHandoffArtifactPath(plan, taskId) {
6916
+ return join5(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
6917
+ }
6918
+ function prHandoffCommand(input, plan, todosProjectPath) {
6919
+ const artifactPath = prHandoffArtifactPath(plan, input.taskId);
6920
+ return [
6921
+ "set -euo pipefail",
6922
+ `export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote2(artifactPath)}`,
6923
+ `export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote2(input.taskId)}`,
6924
+ `export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote2(todosProjectPath)}`,
6925
+ `export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote2(plan.cwd)}`,
6926
+ `export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(plan.path ?? plan.cwd)}`,
6927
+ `export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(plan.branch ?? "")}`,
6928
+ 'if [ ! -s "$OPENLOOPS_PR_HANDOFF_ARTIFACT" ]; then',
6929
+ ` printf 'no PR handoff artifact at %s\\n' "$OPENLOOPS_PR_HANDOFF_ARTIFACT"`,
6930
+ " exit 0",
6931
+ "fi",
6932
+ "bun - <<'BUN'",
6933
+ "const { readFileSync, realpathSync } = await import('node:fs');",
6934
+ "const { spawnSync } = await import('node:child_process');",
6935
+ "const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
6936
+ "const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
6937
+ "const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
6938
+ "const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
6939
+ "const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
6940
+ "const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
6941
+ "const todosBin = process.env.OPENLOOPS_PR_HANDOFF_TODOS_BIN || 'todos';",
6942
+ "const gitBin = process.env.OPENLOOPS_PR_HANDOFF_GIT_BIN || 'git';",
6943
+ "const ghBin = process.env.OPENLOOPS_PR_HANDOFF_GH_BIN || 'gh';",
6944
+ "const raw = readFileSync(artifactPath, 'utf8');",
6945
+ "const artifact = JSON.parse(raw);",
6946
+ "const stringField = (...keys) => {",
6947
+ " for (const key of keys) {",
6948
+ " const value = artifact[key];",
6949
+ " if (typeof value === 'string' && value.trim()) return value.trim();",
6950
+ " }",
6951
+ " return undefined;",
6952
+ "};",
6953
+ "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
6954
+ "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
6955
+ "const todos = (...args) => run(todosBin, todosArgs(...args));",
6956
+ "const comment = (text) => {",
6957
+ " const result = todos('comment', taskId, text);",
6958
+ " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
6959
+ "};",
6960
+ "const repoPath = stringField('worktreePath', 'localRepoPath', 'repoPath', 'cwd') || fallbackWorktree;",
6961
+ "const artifactTaskId = stringField('taskId', 'sourceTaskId', 'originalTaskId');",
6962
+ "const branch = stringField('branch', 'headBranch');",
6963
+ "const base = stringField('base', 'baseBranch') || 'main';",
6964
+ "const remote = stringField('remote') || 'origin';",
6965
+ "let commit = stringField('commit', 'commitSha', 'sha');",
6966
+ "const repo = stringField('githubRepo', 'repoSlug', 'repository');",
6967
+ "const prUrl = stringField('prUrl', 'pullRequestUrl');",
6968
+ "const title = stringField('title', 'prTitle') || `PR handoff for ${taskId}`;",
6969
+ "const body = stringField('body', 'prBody') || [",
6970
+ " `OpenLoops PR handoff for task ${taskId}.`,",
6971
+ " `Commit: ${commit || 'unknown'}`,",
6972
+ " `Branch: ${branch || 'unknown'}`,",
6973
+ " artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
6974
+ " artifact.error ? `Worker network error: ${artifact.error}` : undefined,",
6975
+ "].filter(Boolean).join('\\n\\n');",
6976
+ "const fingerprint = stringField('fingerprint') || `openloops:pr-handoff:${taskId}:${branch || 'missing-branch'}:${commit || 'missing-commit'}`;",
6977
+ "const repoTagSource = (repo || stringField('repo', 'remoteUrl') || repoPath).split(/[/:]/).filter(Boolean).at(-1) || 'unknown';",
6978
+ "const repoTag = `repo:${repoTagSource.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'}`;",
6979
+ "const metadata = {",
6980
+ " route_enabled: true,",
6981
+ " source: 'openloops.pr-handoff',",
6982
+ " original_task_id: taskId,",
6983
+ " repo: repo || stringField('repo', 'remoteUrl') || '',",
6984
+ " branch: branch || '',",
6985
+ " base,",
6986
+ " commit: commit || '',",
6987
+ " artifact_path: artifactPath,",
6988
+ " fingerprint,",
6989
+ " automation: { allowed: true, mode: 'auto' },",
6990
+ " no_tmux_dispatch: true,",
6991
+ "};",
6992
+ "const upsertTask = (why) => {",
6993
+ " const description = [",
6994
+ " `OpenLoops could not complete network PR handoff for original task ${taskId}.`,",
6995
+ " `Reason: ${why}`,",
6996
+ " `Fingerprint: ${fingerprint}`,",
6997
+ " `Repository: ${repo || stringField('repo', 'remoteUrl') || 'unknown'}`,",
6998
+ " `Worktree: ${repoPath}`,",
6999
+ " `Branch: ${branch || 'unknown'}`,",
7000
+ " `Base: ${base}`,",
7001
+ " `Commit: ${commit || 'unknown'}`,",
7002
+ " `Artifact: ${artifactPath}`,",
7003
+ " artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
7004
+ " artifact.error ? `Worker error: ${artifact.error}` : undefined,",
7005
+ " '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.',",
7006
+ " ].filter(Boolean).join('\\n\\n');",
7007
+ " const result = todos(",
7008
+ " 'task',",
7009
+ " 'upsert',",
7010
+ " '--fingerprint', fingerprint,",
7011
+ " '--title', `PR handoff for ${taskId}`,",
7012
+ " '-d', description,",
7013
+ " '-p', 'high',",
7014
+ " '-t', ['auto:route', 'pr-handoff', 'github', 'network', repoTag].join(','),",
7015
+ " '--metadata-json', JSON.stringify(metadata),",
7016
+ " '--working-dir', repoPath,",
7017
+ " );",
7018
+ " if (result.status !== 0) throw new Error(`todos task upsert failed: ${result.stderr || result.stdout || result.status}`);",
7019
+ " comment(`openloops:pr-handoff=pending task=${taskId} artifact=${artifactPath} fingerprint=${fingerprint} reason=${why}`);",
7020
+ " console.log(`queued PR handoff task fingerprint=${fingerprint}`);",
7021
+ "};",
7022
+ "const queueNetworkHandoff = (why) => { upsertTask(why); process.exit(0); };",
7023
+ "const invalidArtifact = (why) => {",
7024
+ " comment(`openloops:pr-handoff=invalid task=${taskId} artifact=${artifactPath} reason=${why}`);",
7025
+ " console.error(`invalid PR handoff artifact: ${why}`);",
7026
+ " process.exit(0);",
7027
+ "};",
7028
+ "const canonicalPath = (path) => {",
7029
+ " try { return realpathSync(path); } catch { return path; }",
7030
+ "};",
7031
+ "if (artifactTaskId && artifactTaskId !== taskId) invalidArtifact(`artifact task id ${artifactTaskId} does not match expected ${taskId}`);",
7032
+ "if (!branch || !commit) invalidArtifact('artifact missing branch or commit');",
7033
+ "const topLevel = run(gitBin, ['-C', repoPath, 'rev-parse', '--show-toplevel']);",
7034
+ "if (topLevel.status !== 0) invalidArtifact(`artifact repoPath is not a git worktree: ${String(topLevel.stderr || topLevel.stdout || topLevel.status).slice(0, 300)}`);",
7035
+ "const actualRoot = canonicalPath(String(topLevel.stdout || '').trim());",
7036
+ "const wantedRoot = canonicalPath(expectedRoot);",
7037
+ "if (actualRoot !== wantedRoot) invalidArtifact(`artifact repo root mismatch: expected ${wantedRoot}, got ${actualRoot}`);",
7038
+ "const currentBranch = run(gitBin, ['-C', repoPath, 'branch', '--show-current']);",
7039
+ "const actualBranch = String(currentBranch.stdout || '').trim();",
7040
+ "if (currentBranch.status !== 0 || !actualBranch) invalidArtifact(`could not resolve current branch for artifact repo: ${String(currentBranch.stderr || currentBranch.stdout || currentBranch.status).slice(0, 300)}`);",
7041
+ "if (expectedBranch && branch !== expectedBranch) invalidArtifact(`artifact branch ${branch} does not match expected ${expectedBranch}`);",
7042
+ "if (branch !== actualBranch) invalidArtifact(`artifact branch ${branch} does not match current worktree branch ${actualBranch}`);",
7043
+ "const resolvedCommit = run(gitBin, ['-C', repoPath, 'rev-parse', '--verify', `${commit}^{commit}`]);",
7044
+ "if (resolvedCommit.status !== 0) invalidArtifact(`artifact commit is not present in repo: ${String(resolvedCommit.stderr || resolvedCommit.stdout || resolvedCommit.status).slice(0, 300)}`);",
7045
+ "commit = String(resolvedCommit.stdout || commit).trim();",
7046
+ "const reachable = run(gitBin, ['-C', repoPath, 'merge-base', '--is-ancestor', commit, 'HEAD']);",
7047
+ "if (reachable.status !== 0) invalidArtifact(`artifact commit ${commit} is not reachable from HEAD`);",
7048
+ "if (prUrl) {",
7049
+ ` const viewed = run(ghBin, ['pr', 'view', prUrl, '--json', 'url,headRefName', '--jq', '.url + "\\\\n" + .headRefName']);`,
7050
+ " if (viewed.status !== 0) queueNetworkHandoff(`could not verify existing PR URL: ${String(viewed.stderr || viewed.stdout || viewed.status).slice(0, 300)}`);",
7051
+ " const [verifiedUrl, verifiedHead] = String(viewed.stdout || '').trim().split(/\\r?\\n/);",
7052
+ " if (!verifiedUrl || !/^https?:\\/\\//.test(verifiedUrl)) invalidArtifact('verified PR URL was missing or invalid');",
7053
+ " if (verifiedHead && verifiedHead !== branch) invalidArtifact(`verified PR head ${verifiedHead} does not match artifact branch ${branch}`);",
7054
+ " comment(`openloops:pr-handoff=done task=${taskId} pr=${verifiedUrl} commit=${commit} branch=${branch}`);",
7055
+ " console.log(`PR handoff already complete: ${verifiedUrl}`);",
7056
+ " process.exit(0);",
7057
+ "}",
7058
+ "const push = run(gitBin, ['-C', repoPath, 'push', remote, `${commit}:refs/heads/${branch}`]);",
7059
+ "if (push.status !== 0) {",
7060
+ " upsertTask(`git push failed: ${String(push.stderr || push.stdout || push.status).slice(0, 300)}`);",
7061
+ " process.exit(0);",
7062
+ "}",
7063
+ "const ghRepoArgs = repo ? ['--repo', repo] : [];",
7064
+ "const existing = run(ghBin, ['pr', 'list', ...ghRepoArgs, '--head', branch, '--state', 'all', '--json', 'url', '--jq', '.[0].url']);",
7065
+ "let finalPrUrl = existing.status === 0 ? String(existing.stdout || '').trim() : '';",
7066
+ "if (!finalPrUrl) {",
7067
+ " const created = run(ghBin, ['pr', 'create', ...ghRepoArgs, '--base', base, '--head', branch, '--title', title, '--body', body], { cwd: repoPath });",
7068
+ " if (created.status !== 0) {",
7069
+ " upsertTask(`gh pr create failed: ${String(created.stderr || created.stdout || created.status).slice(0, 300)}`);",
7070
+ " process.exit(0);",
7071
+ " }",
7072
+ " finalPrUrl = String(created.stdout || '').trim().split(/\\r?\\n/).find((line) => /^https?:\\/\\//.test(line)) || String(created.stdout || '').trim();",
7073
+ "}",
7074
+ "comment(`openloops:pr-handoff=done task=${taskId} pr=${finalPrUrl} commit=${commit} branch=${branch}`);",
7075
+ "console.log(`PR handoff complete: ${finalPrUrl}`);",
7076
+ "BUN"
7077
+ ].join(`
7078
+ `);
7079
+ }
7080
+ function sourceTaskGateCommand(todosProjectPath, taskId) {
7081
+ return [
7082
+ "set -euo pipefail",
7083
+ `todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
7084
+ `printf "source task %s resolved in todos project %s\\n" ${shellQuote2(taskId)} ${shellQuote2(todosProjectPath)}`
7085
+ ].join(`
7086
+ `);
7087
+ }
6559
7088
  function normalizeWorktreeMode(mode) {
6560
7089
  const value = mode ?? "auto";
6561
7090
  if (!["auto", "required", "off", "main"].includes(value)) {
@@ -6571,7 +7100,7 @@ function defaultWorktreeRoot(root) {
6571
7100
  return join5(homedir3(), ".hasna", "loops", "worktrees");
6572
7101
  }
6573
7102
  function gitRootFor(path) {
6574
- if (!existsSync4(path))
7103
+ if (!existsSync5(path))
6575
7104
  return;
6576
7105
  try {
6577
7106
  return execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
@@ -6583,7 +7112,7 @@ function gitRootFor(path) {
6583
7112
  }
6584
7113
  }
6585
7114
  function gitCommonDirFor(path) {
6586
- if (!existsSync4(path))
7115
+ if (!existsSync5(path))
6587
7116
  return;
6588
7117
  try {
6589
7118
  const raw = execFileSync("git", ["-C", path, "rev-parse", "--git-common-dir"], {
@@ -6795,7 +7324,8 @@ function agentTarget(input, prompt, role, seed, plan) {
6795
7324
  ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
6796
7325
  },
6797
7326
  account: accountForRole(input, role, seed),
6798
- timeoutMs: agentTimeoutMs(input)
7327
+ timeoutMs: agentTimeoutMs(input),
7328
+ idleTimeoutMs: role === "verifier" ? verifierIdleTimeoutMs(input) : undefined
6799
7329
  };
6800
7330
  }
6801
7331
  function workflowStepsWithWorktree(plan, steps) {
@@ -6975,7 +7505,7 @@ function customTemplateSummary(definition, sourcePath) {
6975
7505
  function readCustomTemplateFile(file) {
6976
7506
  let parsed;
6977
7507
  try {
6978
- parsed = JSON.parse(readFileSync3(file, "utf8"));
7508
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
6979
7509
  } catch (error) {
6980
7510
  const message = error instanceof Error ? error.message : String(error);
6981
7511
  throw new Error(`failed to read custom template ${file}: ${message}`);
@@ -7001,7 +7531,7 @@ function assertNoTemplateCollisions(entries) {
7001
7531
  }
7002
7532
  function loadCustomLoopTemplatesRaw() {
7003
7533
  const dir = customLoopTemplatesDir();
7004
- if (!existsSync4(dir))
7534
+ if (!existsSync5(dir))
7005
7535
  return [];
7006
7536
  return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
7007
7537
  const file = join5(dir, entry.name);
@@ -7125,7 +7655,7 @@ function importCustomLoopTemplate(file, opts = {}) {
7125
7655
  const entry = readCustomTemplateFile(source);
7126
7656
  const dir = ensureCustomLoopTemplatesDir();
7127
7657
  const destination = join5(dir, `${entry.definition.id}.json`);
7128
- const replaced = existsSync4(destination);
7658
+ const replaced = existsSync5(destination);
7129
7659
  const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== resolve2(destination));
7130
7660
  assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
7131
7661
  if (replaced) {
@@ -7221,6 +7751,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
7221
7751
  `- Record verification: todos --project ${todosProjectPath} comment ${input.taskId} "<verification evidence or blocker>"`,
7222
7752
  `- If valid and complete: todos --project ${todosProjectPath} done ${input.taskId}`,
7223
7753
  "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.",
7754
+ verifierRuntimeGuidance(input),
7224
7755
  "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
7756
  "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
7757
  "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 +7764,24 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
7233
7764
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
7234
7765
  version: 1,
7235
7766
  steps: workflowStepsWithWorktree(plan, [
7767
+ {
7768
+ id: "source-task-gate",
7769
+ name: "Source Task Gate",
7770
+ description: "Fail before worker execution when the source todos task is not resolvable.",
7771
+ target: {
7772
+ type: "command",
7773
+ command: "bash",
7774
+ args: ["-lc", sourceTaskGateCommand(todosProjectPath, input.taskId)],
7775
+ cwd: plan.cwd,
7776
+ timeoutMs: 60000
7777
+ },
7778
+ timeoutMs: 60000
7779
+ },
7236
7780
  {
7237
7781
  id: "worker",
7238
7782
  name: "Worker",
7239
7783
  description: "Implement the todos task and record evidence.",
7784
+ dependsOn: ["source-task-gate"],
7240
7785
  target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
7241
7786
  timeoutMs: agentTimeoutMs(input)
7242
7787
  },
@@ -7277,6 +7822,14 @@ function renderTaskLifecycleWorkflow(input) {
7277
7822
  reason: plan.reason
7278
7823
  }
7279
7824
  };
7825
+ const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
7826
+ const prHandoffGuidance = input.prHandoff ? [
7827
+ "PR handoff mode is enabled for this lifecycle.",
7828
+ `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}`,
7829
+ "The artifact must include taskId, worktreePath or repoPath, branch, base, commit, remote, validation, and error. Include githubRepo, title, and body when known.",
7830
+ "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."
7831
+ ].join(`
7832
+ `) : "";
7280
7833
  const shared = [
7281
7834
  worktreePrompt(plan),
7282
7835
  `Todos project path: ${todosProjectPath}`,
@@ -7286,7 +7839,8 @@ function renderTaskLifecycleWorkflow(input) {
7286
7839
  "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
7840
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
7288
7841
  "",
7289
- `Task context JSON: ${compactJson(taskContext)}`
7842
+ `Task context JSON: ${compactJson(taskContext)}`,
7843
+ prHandoffGuidance
7290
7844
  ].join(`
7291
7845
  `);
7292
7846
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
@@ -7330,7 +7884,7 @@ function renderTaskLifecycleWorkflow(input) {
7330
7884
  "const latestMarker = markers.at(-1)?.state;",
7331
7885
  "const blockers = [];",
7332
7886
  "if (blockedStatuses.has(status)) blockers.push(`task status is ${status}`);",
7333
- "for (const tag of ['no-auto', 'manual', 'manual-required', 'approval-required']) {",
7887
+ "for (const tag of ['no-auto', 'manual', 'manual-required', 'approval-required', 'blocked', 'completed', 'done', 'cancelled', 'canceled', 'failed', 'archived']) {",
7334
7888
  " if (tags.has(tag)) blockers.push(`task has disallowed tag ${tag}`);",
7335
7889
  "}",
7336
7890
  "for (const [key, source] of records.entries()) {",
@@ -7359,7 +7913,7 @@ function renderTaskLifecycleWorkflow(input) {
7359
7913
  "Include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same comment.",
7360
7914
  `If the task should not proceed automatically, run: todos --project ${todosProjectPath} update ${input.taskId} --status blocked`,
7361
7915
  `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."
7916
+ "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
7917
  ].join(`
7364
7918
  `);
7365
7919
  const plannerPrompt = [
@@ -7372,7 +7926,7 @@ function renderTaskLifecycleWorkflow(input) {
7372
7926
  "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
7927
  `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
7928
  `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."
7929
+ "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
7930
  ].join(`
7377
7931
  `);
7378
7932
  const workerPrompt = [
@@ -7382,8 +7936,9 @@ function renderTaskLifecycleWorkflow(input) {
7382
7936
  shared,
7383
7937
  `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${input.taskId}`,
7384
7938
  "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.",
7939
+ 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
7940
  "Do not mark the task complete in the worker step; the verifier step owns completion after independent validation."
7386
- ].join(`
7941
+ ].filter(Boolean).join(`
7387
7942
  `);
7388
7943
  const verifierPrompt = [
7389
7944
  `/goal Verify todos task ${input.taskId} after the full lifecycle worker step.`,
@@ -7393,75 +7948,108 @@ function renderTaskLifecycleWorkflow(input) {
7393
7948
  `- Record verification: todos --project ${todosProjectPath} comment ${input.taskId} "<verification evidence or blocker>"`,
7394
7949
  `- If valid and complete: todos --project ${todosProjectPath} done ${input.taskId}`,
7395
7950
  "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.",
7951
+ verifierRuntimeGuidance(input),
7952
+ 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
7953
  "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
7954
  "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(`
7955
+ ].filter(Boolean).join(`
7399
7956
  `);
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)
7957
+ const steps = [
7958
+ {
7959
+ id: "source-task-gate",
7960
+ name: "Source Task Gate",
7961
+ description: "Fail before lifecycle agents execute when the source todos task is not resolvable.",
7962
+ target: {
7963
+ type: "command",
7964
+ command: "bash",
7965
+ args: ["-lc", sourceTaskGateCommand(todosProjectPath, input.taskId)],
7966
+ cwd: plan.cwd,
7967
+ timeoutMs: 60000
7411
7968
  },
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
- },
7969
+ timeoutMs: 60000
7970
+ },
7971
+ {
7972
+ id: "triage",
7973
+ name: "Triage",
7974
+ description: "Check task eligibility, duplicates, dependencies, and automation gates.",
7975
+ dependsOn: ["source-task-gate"],
7976
+ target: agentTarget(input, triagePrompt, "triage", input.taskId, plan),
7977
+ timeoutMs: agentTimeoutMs(input)
7978
+ },
7979
+ {
7980
+ id: "triage-gate",
7981
+ name: "Triage Gate",
7982
+ description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
7983
+ dependsOn: ["triage"],
7984
+ target: {
7985
+ type: "command",
7986
+ command: "bash",
7987
+ args: ["-lc", gateCommand("triage")],
7988
+ cwd: plan.cwd,
7424
7989
  timeoutMs: 2 * 60000
7425
7990
  },
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
- },
7991
+ timeoutMs: 2 * 60000
7992
+ },
7993
+ {
7994
+ id: "planner",
7995
+ name: "Planner",
7996
+ description: "Create a concise implementation plan and split unsafe scope before work starts.",
7997
+ dependsOn: ["triage-gate"],
7998
+ target: agentTarget(input, plannerPrompt, "planner", input.taskId, plan),
7999
+ timeoutMs: agentTimeoutMs(input)
8000
+ },
8001
+ {
8002
+ id: "planner-gate",
8003
+ name: "Planner Gate",
8004
+ description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
8005
+ dependsOn: ["planner"],
8006
+ target: {
8007
+ type: "command",
8008
+ command: "bash",
8009
+ args: ["-lc", gateCommand("planner")],
8010
+ cwd: plan.cwd,
7446
8011
  timeoutMs: 2 * 60000
7447
8012
  },
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)
8013
+ timeoutMs: 2 * 60000
8014
+ },
8015
+ {
8016
+ id: "worker",
8017
+ name: "Worker",
8018
+ description: "Implement the todos task according to triage and planner evidence.",
8019
+ dependsOn: ["planner-gate"],
8020
+ target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
8021
+ timeoutMs: agentTimeoutMs(input)
8022
+ }
8023
+ ];
8024
+ if (input.prHandoff) {
8025
+ steps.push({
8026
+ id: "pr-handoff",
8027
+ name: "PR Handoff",
8028
+ description: "Push/open a PR from a worker handoff artifact or queue a bounded network handoff task.",
8029
+ dependsOn: ["worker"],
8030
+ target: {
8031
+ type: "command",
8032
+ command: "bash",
8033
+ args: ["-lc", prHandoffCommand(input, plan, todosProjectPath)],
8034
+ cwd: plan.cwd,
8035
+ timeoutMs: 2 * 60000
7455
8036
  },
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
- ])
8037
+ timeoutMs: 2 * 60000
8038
+ });
8039
+ }
8040
+ steps.push({
8041
+ id: "verifier",
8042
+ name: "Verifier",
8043
+ description: "Adversarially verify worker output and update todos.",
8044
+ dependsOn: [input.prHandoff ? "pr-handoff" : "worker"],
8045
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
8046
+ timeoutMs: agentTimeoutMs(input)
8047
+ });
8048
+ return {
8049
+ name: `task-lifecycle-${input.taskId.slice(0, 8)}-triage-plan-worker-verifier`,
8050
+ description: `Full task lifecycle workflow for ${taskLabel(input)}`,
8051
+ version: 1,
8052
+ steps: workflowStepsWithWorktree(plan, steps)
7465
8053
  };
7466
8054
  }
7467
8055
  function renderEventWorkerVerifierWorkflow(input) {
@@ -7513,6 +8101,7 @@ function renderEventWorkerVerifierWorkflow(input) {
7513
8101
  "You are the verifier agent for an event-triggered OpenLoops workflow.",
7514
8102
  worktreePrompt(plan),
7515
8103
  "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.",
8104
+ verifierRuntimeGuidance(input),
7516
8105
  "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
8106
  "",
7518
8107
  `Event context JSON: ${compactJson(eventContext)}`,
@@ -7567,6 +8156,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
7567
8156
  "You are the verifier step for a bounded OpenLoops agent workflow.",
7568
8157
  worktreePrompt(plan),
7569
8158
  "Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
8159
+ verifierRuntimeGuidance(input),
7570
8160
  "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
8161
  ].join(`
7572
8162
  `);
@@ -7617,7 +8207,8 @@ function renderLifecycleBoundedTemplate(id, values) {
7617
8207
  worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
7618
8208
  worktreeRoot: values.worktreeRoot,
7619
8209
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7620
- timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
8210
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8211
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
7621
8212
  };
7622
8213
  if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
7623
8214
  const taskId = values.taskId ?? "";
@@ -7651,10 +8242,12 @@ function renderLifecycleBoundedTemplate(id, values) {
7651
8242
  permissionMode: values.permissionMode,
7652
8243
  sandbox: values.sandbox,
7653
8244
  manualBreakGlass: booleanVar(values.manualBreakGlass),
8245
+ prHandoff: booleanVar(values.prHandoff),
7654
8246
  worktreeMode: values.worktreeMode ?? "required",
7655
8247
  worktreeRoot: values.worktreeRoot,
7656
8248
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7657
8249
  timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8250
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout),
7658
8251
  eventId: values.eventId,
7659
8252
  eventType: values.eventType
7660
8253
  });
@@ -7779,6 +8372,7 @@ function renderBuiltinLoopTemplate(id, values) {
7779
8372
  worktreeRoot: values.worktreeRoot,
7780
8373
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7781
8374
  timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8375
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout),
7782
8376
  eventId: values.eventId,
7783
8377
  eventType: values.eventType
7784
8378
  });
@@ -7811,7 +8405,8 @@ function renderBuiltinLoopTemplate(id, values) {
7811
8405
  worktreeMode: values.worktreeMode,
7812
8406
  worktreeRoot: values.worktreeRoot,
7813
8407
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7814
- timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
8408
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8409
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
7815
8410
  });
7816
8411
  }
7817
8412
  if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
@@ -7839,7 +8434,8 @@ function renderBuiltinLoopTemplate(id, values) {
7839
8434
  worktreeMode: values.worktreeMode,
7840
8435
  worktreeRoot: values.worktreeRoot,
7841
8436
  worktreeBranchPrefix: values.worktreeBranchPrefix,
7842
- timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
8437
+ timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
8438
+ verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
7843
8439
  });
7844
8440
  }
7845
8441
  throw new Error(`unknown template: ${id}`);
@@ -7946,7 +8542,7 @@ function normalizeWorkflowForStorage(body, context) {
7946
8542
  function workflowBodyFromFile(file, fallbackName, context) {
7947
8543
  try {
7948
8544
  const resolved = resolve3(file);
7949
- return workflowBodyFromJson(JSON.parse(readFileSync4(resolved, "utf8")), fallbackName, { baseDir: dirname4(resolved) });
8545
+ return workflowBodyFromJson(JSON.parse(readFileSync5(resolved, "utf8")), fallbackName, { baseDir: dirname4(resolved) });
7950
8546
  } catch (error) {
7951
8547
  validationFailed(error, context);
7952
8548
  }
@@ -8072,6 +8668,10 @@ function timeoutDuration(raw, label) {
8072
8668
  throw new Error(`${label} must be a duration greater than zero, or none/unlimited`);
8073
8669
  return value;
8074
8670
  }
8671
+ function idleTimeoutDuration(raw, label) {
8672
+ const value = timeoutDuration(raw, label);
8673
+ return value === null ? undefined : value;
8674
+ }
8075
8675
  function parsePolicy(opts) {
8076
8676
  const catchUp = opts.catchUp ?? "latest";
8077
8677
  if (!["none", "latest", "all"].includes(catchUp))
@@ -8088,6 +8688,45 @@ function parsePolicy(opts) {
8088
8688
  leaseMs: positiveDuration(opts.lease, "--lease")
8089
8689
  };
8090
8690
  }
8691
+ function durationLabel(ms) {
8692
+ if (!ms || !Number.isFinite(ms))
8693
+ return "";
8694
+ const units = [
8695
+ [7 * 24 * 60 * 60 * 1000, "w"],
8696
+ [24 * 60 * 60 * 1000, "d"],
8697
+ [60 * 60 * 1000, "h"],
8698
+ [60 * 1000, "m"],
8699
+ [1000, "s"]
8700
+ ];
8701
+ for (const [unitMs, label] of units) {
8702
+ if (ms % unitMs === 0)
8703
+ return `${ms / unitMs}${label}`;
8704
+ }
8705
+ return `${ms}ms`;
8706
+ }
8707
+ function scheduleLabel(schedule) {
8708
+ if (schedule.type === "once")
8709
+ return `once:${schedule.at}`;
8710
+ if (schedule.type === "interval")
8711
+ return `every:${durationLabel(schedule.everyMs) || `${schedule.everyMs}ms`}`;
8712
+ if (schedule.type === "cron")
8713
+ return `cron:${schedule.expression}`;
8714
+ return schedule.minIntervalMs ? `dynamic:min-${durationLabel(schedule.minIntervalMs) || `${schedule.minIntervalMs}ms`}` : "dynamic";
8715
+ }
8716
+ function targetLabel(target) {
8717
+ if (target.type === "command")
8718
+ return `runs command ${target.command}`;
8719
+ if (target.type === "agent")
8720
+ return `runs ${target.provider} agent${target.cwd ? ` in ${target.cwd}` : ""}`;
8721
+ return `runs workflow ${target.workflowId}`;
8722
+ }
8723
+ function defaultLoopDescription(name, schedule, target) {
8724
+ return [
8725
+ `Why: keep ${name} running as an OpenLoops scheduled automation.`,
8726
+ `How: ${targetLabel(target)} on cadence ${scheduleLabel(schedule)}.`,
8727
+ "Outcome: record each run, status, retries, and evidence in OpenLoops for operator review."
8728
+ ].join(" ");
8729
+ }
8091
8730
  function baseCreateInput(name, opts, target) {
8092
8731
  const schedule = parseSchedule({
8093
8732
  at: typeof opts.at === "string" ? opts.at : undefined,
@@ -8103,9 +8742,10 @@ function baseCreateInput(name, opts, target) {
8103
8742
  retryDelay: typeof opts.retryDelay === "string" ? opts.retryDelay : undefined,
8104
8743
  lease: typeof opts.lease === "string" ? opts.lease : undefined
8105
8744
  });
8745
+ const explicitDescription = typeof opts.description === "string" && opts.description.trim() ? opts.description : undefined;
8106
8746
  return {
8107
8747
  name,
8108
- description: typeof opts.description === "string" ? opts.description : undefined,
8748
+ description: explicitDescription ?? defaultLoopDescription(name, schedule, target),
8109
8749
  schedule,
8110
8750
  target,
8111
8751
  goal: goalFromOpts(opts),
@@ -8228,7 +8868,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
8228
8868
  return {
8229
8869
  ok: result.status === 0,
8230
8870
  status: result.status,
8231
- stdout: readFileSync4(stdoutPath, "utf8"),
8871
+ stdout: readFileSync5(stdoutPath, "utf8"),
8232
8872
  stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
8233
8873
  error: result.error ? String(result.error.message || result.error) : ""
8234
8874
  };
@@ -8269,10 +8909,10 @@ function routeCursorsPath() {
8269
8909
  }
8270
8910
  function readRouteCursors() {
8271
8911
  const path = routeCursorsPath();
8272
- if (!existsSync5(path))
8912
+ if (!existsSync6(path))
8273
8913
  return {};
8274
8914
  try {
8275
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
8915
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
8276
8916
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
8277
8917
  } catch {
8278
8918
  return {};
@@ -8436,31 +9076,46 @@ function taskEventField(data, keys) {
8436
9076
  }
8437
9077
  return;
8438
9078
  }
8439
- function objectField(value) {
9079
+ function objectField2(value) {
8440
9080
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
8441
9081
  }
8442
9082
  function nestedObject(input, key) {
8443
- return objectField(input[key]);
9083
+ return objectField2(input[key]);
8444
9084
  }
8445
9085
  function taskEventRecords(data, metadata) {
8446
9086
  const records = [data];
8447
9087
  const dataTask = nestedObject(data, "task");
8448
- if (dataTask)
9088
+ if (dataTask) {
8449
9089
  records.push(dataTask);
9090
+ const dataTaskMetadata = nestedObject(dataTask, "metadata");
9091
+ if (dataTaskMetadata)
9092
+ records.push(dataTaskMetadata);
9093
+ }
8450
9094
  const dataPayload = nestedObject(data, "payload");
8451
9095
  if (dataPayload) {
8452
9096
  records.push(dataPayload);
9097
+ const payloadMetadata = nestedObject(dataPayload, "metadata");
9098
+ if (payloadMetadata)
9099
+ records.push(payloadMetadata);
8453
9100
  const payloadTask = nestedObject(dataPayload, "task");
8454
- if (payloadTask)
9101
+ if (payloadTask) {
8455
9102
  records.push(payloadTask);
9103
+ const payloadTaskMetadata = nestedObject(payloadTask, "metadata");
9104
+ if (payloadTaskMetadata)
9105
+ records.push(payloadTaskMetadata);
9106
+ }
8456
9107
  }
8457
9108
  const dataMetadata = nestedObject(data, "metadata");
8458
9109
  if (dataMetadata)
8459
9110
  records.push(dataMetadata);
8460
9111
  records.push(metadata);
8461
9112
  const metadataTask = nestedObject(metadata, "task");
8462
- if (metadataTask)
9113
+ if (metadataTask) {
8463
9114
  records.push(metadataTask);
9115
+ const metadataTaskMetadata = nestedObject(metadataTask, "metadata");
9116
+ if (metadataTaskMetadata)
9117
+ records.push(metadataTaskMetadata);
9118
+ }
8464
9119
  const metadataAutomation = nestedObject(metadata, "automation");
8465
9120
  if (metadataAutomation)
8466
9121
  records.push(metadataAutomation);
@@ -8472,6 +9127,15 @@ function booleanLike(value) {
8472
9127
  function hasTruthyField(records, keys) {
8473
9128
  return records.some((record) => keys.some((key) => booleanLike(record[key])));
8474
9129
  }
9130
+ function firstTruthyField(records, keys) {
9131
+ for (const record of records) {
9132
+ for (const key of keys) {
9133
+ if (booleanLike(record[key]))
9134
+ return key;
9135
+ }
9136
+ }
9137
+ return;
9138
+ }
8475
9139
  function automationRecords(data, metadata) {
8476
9140
  const records = [];
8477
9141
  const dataAutomation = nestedObject(data, "automation");
@@ -8481,14 +9145,26 @@ function automationRecords(data, metadata) {
8481
9145
  const dataTaskAutomation = dataTask ? nestedObject(dataTask, "automation") : undefined;
8482
9146
  if (dataTaskAutomation)
8483
9147
  records.push(dataTaskAutomation);
9148
+ const dataTaskMetadata = dataTask ? nestedObject(dataTask, "metadata") : undefined;
9149
+ const dataTaskMetadataAutomation = dataTaskMetadata ? nestedObject(dataTaskMetadata, "automation") : undefined;
9150
+ if (dataTaskMetadataAutomation)
9151
+ records.push(dataTaskMetadataAutomation);
8484
9152
  const dataPayload = nestedObject(data, "payload");
8485
9153
  const payloadAutomation = dataPayload ? nestedObject(dataPayload, "automation") : undefined;
8486
9154
  if (payloadAutomation)
8487
9155
  records.push(payloadAutomation);
9156
+ const payloadMetadata = dataPayload ? nestedObject(dataPayload, "metadata") : undefined;
9157
+ const payloadMetadataAutomation = payloadMetadata ? nestedObject(payloadMetadata, "automation") : undefined;
9158
+ if (payloadMetadataAutomation)
9159
+ records.push(payloadMetadataAutomation);
8488
9160
  const payloadTask = dataPayload ? nestedObject(dataPayload, "task") : undefined;
8489
9161
  const payloadTaskAutomation = payloadTask ? nestedObject(payloadTask, "automation") : undefined;
8490
9162
  if (payloadTaskAutomation)
8491
9163
  records.push(payloadTaskAutomation);
9164
+ const payloadTaskMetadata = payloadTask ? nestedObject(payloadTask, "metadata") : undefined;
9165
+ const payloadTaskMetadataAutomation = payloadTaskMetadata ? nestedObject(payloadTaskMetadata, "automation") : undefined;
9166
+ if (payloadTaskMetadataAutomation)
9167
+ records.push(payloadTaskMetadataAutomation);
8492
9168
  const dataMetadata = nestedObject(data, "metadata");
8493
9169
  const dataMetadataAutomation = dataMetadata ? nestedObject(dataMetadata, "automation") : undefined;
8494
9170
  if (dataMetadataAutomation)
@@ -8500,9 +9176,13 @@ function automationRecords(data, metadata) {
8500
9176
  const metadataTaskAutomation = metadataTask ? nestedObject(metadataTask, "automation") : undefined;
8501
9177
  if (metadataTaskAutomation)
8502
9178
  records.push(metadataTaskAutomation);
9179
+ const metadataTaskMetadata = metadataTask ? nestedObject(metadataTask, "metadata") : undefined;
9180
+ const metadataTaskMetadataAutomation = metadataTaskMetadata ? nestedObject(metadataTaskMetadata, "automation") : undefined;
9181
+ if (metadataTaskMetadataAutomation)
9182
+ records.push(metadataTaskMetadataAutomation);
8503
9183
  return records;
8504
9184
  }
8505
- function tagsFromValue(value) {
9185
+ function tagsFromValue2(value) {
8506
9186
  if (Array.isArray(value))
8507
9187
  return value.map((entry) => String(entry).trim()).filter(Boolean);
8508
9188
  if (typeof value === "string")
@@ -8512,40 +9192,381 @@ function tagsFromValue(value) {
8512
9192
  function taskEventTags(records) {
8513
9193
  const tags = new Set;
8514
9194
  for (const record of records) {
8515
- for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags))
9195
+ for (const tag of tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags))
8516
9196
  tags.add(tag);
8517
9197
  }
8518
9198
  return [...tags];
8519
9199
  }
9200
+ var ROUTE_DISALLOWED_TASK_TAGS = new Set([
9201
+ "no-auto",
9202
+ "manual",
9203
+ "manual-required",
9204
+ "approval-required",
9205
+ "blocked",
9206
+ "completed",
9207
+ "done",
9208
+ "cancelled",
9209
+ "canceled",
9210
+ "failed",
9211
+ "archived"
9212
+ ]);
9213
+ var ROUTE_MANUAL_GATE_FIELDS = [
9214
+ "no_auto",
9215
+ "noAuto",
9216
+ "manual",
9217
+ "manual_required",
9218
+ "manualRequired",
9219
+ "requires_approval",
9220
+ "requiresApproval",
9221
+ "approval_required",
9222
+ "approvalRequired"
9223
+ ];
8520
9224
  function taskRouteEligibility(data, metadata) {
8521
9225
  const records = taskEventRecords(data, metadata);
9226
+ const automation = automationRecords(data, metadata);
8522
9227
  const tags = taskEventTags(records);
8523
- const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automationRecords(data, metadata), ["allowed"]);
9228
+ const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automation, ["allowed"]);
8524
9229
  if (!hasRouteOptIn)
8525
9230
  return { eligible: false, reason: "missing explicit route opt-in", tags };
8526
9231
  const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
8527
9232
  if (status && ["blocked", "completed", "done", "cancelled", "canceled", "failed", "archived"].includes(status)) {
8528
9233
  return { eligible: false, reason: `task status is not routable: ${status}`, tags };
8529
9234
  }
8530
- const disallowedTags = tags.filter((tag) => ["no-auto", "manual", "manual-required", "approval-required"].includes(tag));
9235
+ const disallowedTags = tags.filter((tag) => ROUTE_DISALLOWED_TASK_TAGS.has(tag.toLowerCase()));
8531
9236
  if (disallowedTags.length)
8532
9237
  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 };
9238
+ const manualGateField = firstTruthyField([...records, ...automation], ROUTE_MANUAL_GATE_FIELDS);
9239
+ if (manualGateField) {
9240
+ return { eligible: false, reason: `task metadata requires manual or approval-gated handling: ${manualGateField}`, tags };
8545
9241
  }
8546
9242
  return { eligible: true, tags };
8547
9243
  }
8548
- function skippedDrainTask(task, event, reason) {
9244
+ var PR_AUTHOR_FIELDS = [
9245
+ "github_author",
9246
+ "githubAuthor",
9247
+ "github_pr_author",
9248
+ "githubPrAuthor",
9249
+ "pr_author",
9250
+ "prAuthor",
9251
+ "pull_request_author",
9252
+ "pullRequestAuthor",
9253
+ "author_login",
9254
+ "authorLogin"
9255
+ ];
9256
+ var PR_REVIEWER_FIELDS = [
9257
+ "github_reviewer",
9258
+ "githubReviewer",
9259
+ "github_review_actor",
9260
+ "githubReviewActor",
9261
+ "github_review_login",
9262
+ "githubReviewLogin",
9263
+ "github_actor",
9264
+ "githubActor",
9265
+ "reviewer_login",
9266
+ "reviewerLogin",
9267
+ "review_actor",
9268
+ "reviewActor",
9269
+ "merge_actor",
9270
+ "mergeActor"
9271
+ ];
9272
+ var PR_REVIEWER_POOL_FIELDS = [
9273
+ "github_reviewer_pool",
9274
+ "githubReviewerPool",
9275
+ "github_review_pool",
9276
+ "githubReviewPool",
9277
+ "github_reviewers",
9278
+ "githubReviewers",
9279
+ "reviewer_pool",
9280
+ "reviewerPool",
9281
+ "reviewers"
9282
+ ];
9283
+ var PR_REVIEW_REQUIRED_FIELDS = [
9284
+ "github_review_required",
9285
+ "githubReviewRequired",
9286
+ "pr_review_required",
9287
+ "prReviewRequired",
9288
+ "review_required",
9289
+ "reviewRequired",
9290
+ "requires_non_author_review",
9291
+ "requiresNonAuthorReview",
9292
+ "branch_protection_review_required",
9293
+ "branchProtectionReviewRequired"
9294
+ ];
9295
+ function githubLogin(value) {
9296
+ if (!value?.trim())
9297
+ return;
9298
+ const login = value.trim().replace(/^@/, "");
9299
+ return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) ? login : undefined;
9300
+ }
9301
+ function githubLogins(values) {
9302
+ const seen = new Set;
9303
+ const logins = [];
9304
+ for (const value of values) {
9305
+ const login = githubLogin(value);
9306
+ if (!login)
9307
+ continue;
9308
+ const key = login.toLowerCase();
9309
+ if (seen.has(key))
9310
+ continue;
9311
+ seen.add(key);
9312
+ logins.push(login);
9313
+ }
9314
+ return logins;
9315
+ }
9316
+ function routeEvidenceText(records) {
9317
+ const fields = [
9318
+ "title",
9319
+ "task_title",
9320
+ "taskTitle",
9321
+ "description",
9322
+ "body",
9323
+ "reason",
9324
+ "message",
9325
+ "fingerprint",
9326
+ "reviewDecision",
9327
+ "review_decision",
9328
+ "mergeStateStatus",
9329
+ "merge_state_status"
9330
+ ];
9331
+ const values = [];
9332
+ for (const record of records) {
9333
+ for (const field of fields)
9334
+ values.push(...recordFieldValues(record, field));
9335
+ values.push(...tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags));
9336
+ }
9337
+ return values.join(`
9338
+ `);
9339
+ }
9340
+ function authorFromPrText(text) {
9341
+ const patterns = [
9342
+ /\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
9343
+ /\bPR\s*#?\d+\s+author\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
9344
+ /\bgithub\s+author\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i
9345
+ ];
9346
+ for (const pattern of patterns) {
9347
+ const match = pattern.exec(text);
9348
+ const login = githubLogin(match?.[1]);
9349
+ if (login)
9350
+ return login;
9351
+ }
9352
+ return;
9353
+ }
9354
+ function prReviewRoutingDecision(data, metadata, opts) {
9355
+ const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
9356
+ const text = routeEvidenceText(records);
9357
+ const signals = [];
9358
+ 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);
9359
+ const reviewRequiredByField = hasTruthyField(records, PR_REVIEW_REQUIRED_FIELDS);
9360
+ 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);
9361
+ const mergeBlockedByText = /mergestatestatus\s*[:=]\s*blocked/i.test(text) || /\bmerge state status\b[\s:=]+blocked/i.test(text);
9362
+ const approvalIntent = /\b(approve|approval|merge|review)\b/i.test(text);
9363
+ if (reviewRequiredByField)
9364
+ signals.push("review-required-field");
9365
+ if (reviewRequiredByText)
9366
+ signals.push("review-required-text");
9367
+ if (mergeBlockedByText)
9368
+ signals.push("merge-blocked-text");
9369
+ if (approvalIntent && hasPrReference)
9370
+ signals.push("pr-approval-intent");
9371
+ const required = hasPrReference && (reviewRequiredByField || reviewRequiredByText || mergeBlockedByText || approvalIntent);
9372
+ if (!required)
9373
+ return { required: false, allowed: true, reviewers: [], signals };
9374
+ const author = githubLogin(firstRouteField(records, PR_AUTHOR_FIELDS)) ?? authorFromPrText(text);
9375
+ const reviewers = githubLogins([
9376
+ opts.githubReviewer,
9377
+ ...splitList(opts.githubReviewerPool) ?? [],
9378
+ ...PR_REVIEWER_FIELDS.flatMap((field) => routeFieldValues(records, field)),
9379
+ ...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field))
9380
+ ]);
9381
+ const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
9382
+ if (!author) {
9383
+ return {
9384
+ required: true,
9385
+ allowed: false,
9386
+ reason: "PR approval/merge route requires PR author evidence before selecting a non-author GitHub reviewer",
9387
+ reviewers,
9388
+ signals
9389
+ };
9390
+ }
9391
+ if (!reviewers.length) {
9392
+ return {
9393
+ required: true,
9394
+ allowed: false,
9395
+ 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",
9396
+ author,
9397
+ reviewers,
9398
+ signals
9399
+ };
9400
+ }
9401
+ if (!selectedReviewer) {
9402
+ return {
9403
+ required: true,
9404
+ allowed: false,
9405
+ reason: `PR approval/merge route reviewer candidates match PR author ${author}; self-review is not routable`,
9406
+ author,
9407
+ reviewers,
9408
+ signals
9409
+ };
9410
+ }
9411
+ return {
9412
+ required: true,
9413
+ allowed: true,
9414
+ author,
9415
+ reviewers,
9416
+ selectedReviewer,
9417
+ signals
9418
+ };
9419
+ }
9420
+ var SUPPORTED_AGENT_PROVIDERS = new Set(["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"]);
9421
+ var PROVIDER_HINT_FIELDS = ["provider_hint", "providerHint", "route_provider", "routeProvider", "agent_provider", "agentProvider"];
9422
+ var AUTH_PROFILE_HINT_FIELDS = ["auth_profile", "authProfile", "codewith_profile", "codewithProfile", "profile"];
9423
+ var AUTH_PROFILE_POOL_HINT_FIELDS = ["auth_profile_pool", "authProfilePool", "codewith_profile_pool", "codewithProfilePool", "profile_pool", "profilePool"];
9424
+ var ACCOUNT_HINT_FIELDS = ["account", "account_profile", "accountProfile", "openaccounts_profile", "openaccountsProfile"];
9425
+ var ACCOUNT_POOL_HINT_FIELDS = ["account_pool", "accountPool", "openaccounts_pool", "openaccountsPool"];
9426
+ var ACCOUNT_TOOL_HINT_FIELDS = ["account_tool", "accountTool", "openaccounts_tool", "openaccountsTool"];
9427
+ function canonicalRouteField(value) {
9428
+ return value.replace(/[^a-z0-9]/gi, "").toLowerCase();
9429
+ }
9430
+ function normalizeAgentProvider(value, source) {
9431
+ const provider = value?.trim().toLowerCase();
9432
+ if (provider && SUPPORTED_AGENT_PROVIDERS.has(provider))
9433
+ return provider;
9434
+ const expected = [...SUPPORTED_AGENT_PROVIDERS].join(", ");
9435
+ throw new Error(`unsupported provider${provider ? ` "${provider}"` : ""} from ${source}; expected one of: ${expected}`);
9436
+ }
9437
+ function stringValuesFromUnknown(value) {
9438
+ if (Array.isArray(value))
9439
+ return value.flatMap((entry) => stringValuesFromUnknown(entry));
9440
+ if (typeof value === "string")
9441
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
9442
+ if (typeof value === "number" || typeof value === "boolean")
9443
+ return [String(value)];
9444
+ return [];
9445
+ }
9446
+ function recordFieldValues(record, field) {
9447
+ const expected = canonicalRouteField(field);
9448
+ const values = [];
9449
+ for (const [key, value] of Object.entries(record)) {
9450
+ if (canonicalRouteField(key) === expected)
9451
+ values.push(...stringValuesFromUnknown(value));
9452
+ }
9453
+ return values;
9454
+ }
9455
+ function routeFieldValues(records, field) {
9456
+ if (canonicalRouteField(field) === "tags")
9457
+ return taskEventTags(records);
9458
+ return records.flatMap((record) => recordFieldValues(record, field));
9459
+ }
9460
+ function firstRouteField(records, fields) {
9461
+ for (const field of fields) {
9462
+ const value = routeFieldValues(records, field).find(Boolean);
9463
+ if (value)
9464
+ return value;
9465
+ }
9466
+ return;
9467
+ }
9468
+ function routeFieldList(records, fields) {
9469
+ for (const field of fields) {
9470
+ const values = routeFieldValues(records, field);
9471
+ if (values.length)
9472
+ return values;
9473
+ }
9474
+ return;
9475
+ }
9476
+ function parseProviderRoutingRule(raw) {
9477
+ const rule = raw.trim();
9478
+ const selectorIndex = rule.indexOf(":");
9479
+ if (selectorIndex <= 0)
9480
+ throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
9481
+ const selector = rule.slice(0, selectorIndex).trim();
9482
+ const route = rule.slice(selectorIndex + 1).trim();
9483
+ const equalsIndex = selector.indexOf("=");
9484
+ if (equalsIndex <= 0)
9485
+ throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
9486
+ const field = selector.slice(0, equalsIndex).trim();
9487
+ const value = selector.slice(equalsIndex + 1).trim();
9488
+ const providerIndex = route.indexOf(":");
9489
+ const providerRaw = providerIndex >= 0 ? route.slice(0, providerIndex).trim() : route;
9490
+ const profilesRaw = providerIndex >= 0 ? route.slice(providerIndex + 1).trim() : undefined;
9491
+ if (!field || !value || !providerRaw)
9492
+ throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
9493
+ return {
9494
+ raw,
9495
+ field,
9496
+ value,
9497
+ provider: normalizeAgentProvider(providerRaw, `provider rule ${field}=${value}`),
9498
+ profiles: splitList(profilesRaw)
9499
+ };
9500
+ }
9501
+ function parseProviderRoutingRules(values) {
9502
+ return (values ?? []).map(parseProviderRoutingRule);
9503
+ }
9504
+ function providerRuleMatches(rule, records) {
9505
+ const expected = rule.value.trim().toLowerCase();
9506
+ return routeFieldValues(records, rule.field).some((value) => value.trim().toLowerCase() === expected);
9507
+ }
9508
+ function accountRef(profile, tool) {
9509
+ return profile ? { profile, tool } : undefined;
9510
+ }
9511
+ function accountRefs(profiles, tool) {
9512
+ return profiles?.length ? profiles.map((profile) => ({ profile, tool })) : undefined;
9513
+ }
9514
+ function providerRoutingPublic(decision) {
9515
+ return {
9516
+ provider: decision.provider,
9517
+ source: decision.source,
9518
+ reason: decision.reason,
9519
+ rule: decision.rule,
9520
+ authProfile: decision.authProfile,
9521
+ authProfilePool: decision.authProfilePool,
9522
+ account: decision.account,
9523
+ accountPool: decision.accountPool
9524
+ };
9525
+ }
9526
+ function resolveProviderRouting(data, metadata, opts) {
9527
+ const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
9528
+ const matchedRule = parseProviderRoutingRules(opts.providerRule).find((rule) => providerRuleMatches(rule, records));
9529
+ const metadataProvider = matchedRule || opts.provider ? undefined : firstRouteField(records, PROVIDER_HINT_FIELDS);
9530
+ const provider = matchedRule ? matchedRule.provider : opts.provider ? normalizeAgentProvider(opts.provider, "--provider") : metadataProvider ? normalizeAgentProvider(metadataProvider, "task metadata provider hint") : "codewith";
9531
+ const source = matchedRule ? "rule" : opts.provider ? "option" : metadataProvider ? "metadata" : "default";
9532
+ const metadataProfilePool = routeFieldList(records, provider === "codewith" ? AUTH_PROFILE_POOL_HINT_FIELDS : [...ACCOUNT_POOL_HINT_FIELDS, ...AUTH_PROFILE_POOL_HINT_FIELDS]);
9533
+ const metadataProfile = firstRouteField(records, provider === "codewith" ? AUTH_PROFILE_HINT_FIELDS : [...ACCOUNT_HINT_FIELDS, ...AUTH_PROFILE_HINT_FIELDS]);
9534
+ const profilePool = matchedRule?.profiles ?? metadataProfilePool;
9535
+ const profile = metadataProfile;
9536
+ const metadataAccountTool = firstRouteField(records, ACCOUNT_TOOL_HINT_FIELDS);
9537
+ const accountTool = opts.accountTool ?? (matchedRule?.profiles ? undefined : metadataAccountTool) ?? (provider === "codewith" ? undefined : provider);
9538
+ const hasResolvedAccountPool = provider !== "codewith" && Boolean((profilePool?.length ?? 0) || profile);
9539
+ if (opts.accountTool && !opts.account && !opts.accountPool && !opts.triageAccount && !opts.plannerAccount && !opts.workerAccount && !opts.verifierAccount && !hasResolvedAccountPool) {
9540
+ throw new Error("--account-tool requires --account, --account-pool, --triage-account, --planner-account, --worker-account, --verifier-account, metadata account hints, or provider-rule profiles");
9541
+ }
9542
+ if (provider === "codewith") {
9543
+ const cliAuthProfilePool = splitList(opts.authProfilePool);
9544
+ return {
9545
+ provider,
9546
+ source,
9547
+ reason: matchedRule ? `matched provider rule ${matchedRule.field}=${matchedRule.value}` : metadataProvider ? "selected provider from task metadata" : opts.provider ? "selected provider from --provider" : "selected default provider",
9548
+ rule: matchedRule,
9549
+ authProfile: opts.authProfile ?? profile,
9550
+ authProfilePool: matchedRule?.profiles ?? cliAuthProfilePool ?? (opts.authProfile ? undefined : metadataProfilePool),
9551
+ account: accountRef(opts.account, opts.accountTool),
9552
+ accountPool: accountPoolFromOpts(opts)
9553
+ };
9554
+ }
9555
+ if (opts.authProfile || opts.authProfilePool) {
9556
+ throw new Error(`--auth-profile and --auth-profile-pool are supported only for --provider codewith; use OpenAccounts account profiles for ${provider}`);
9557
+ }
9558
+ const cliAccountPool = accountPoolFromOpts(opts);
9559
+ const account = opts.account ? accountRef(opts.account, opts.accountTool ?? provider) : accountRef(profile, accountTool);
9560
+ return {
9561
+ provider,
9562
+ source,
9563
+ reason: matchedRule ? `matched provider rule ${matchedRule.field}=${matchedRule.value}` : metadataProvider ? "selected provider from task metadata" : opts.provider ? "selected provider from --provider" : "selected default provider",
9564
+ rule: matchedRule,
9565
+ account,
9566
+ accountPool: matchedRule?.profiles ? accountRefs(matchedRule.profiles, accountTool) : cliAccountPool ?? (opts.account ? undefined : accountRefs(metadataProfilePool, accountTool))
9567
+ };
9568
+ }
9569
+ function skippedDrainTask(task, event, reason, extra = {}) {
8549
9570
  const taskId = taskField(task, ["id", "task_id", "taskId"]) ?? event?.subject ?? "unknown";
8550
9571
  return {
8551
9572
  kind: "skipped",
@@ -8554,7 +9575,8 @@ function skippedDrainTask(task, event, reason) {
8554
9575
  reason,
8555
9576
  taskId,
8556
9577
  event,
8557
- routeError: true
9578
+ routeError: true,
9579
+ ...extra
8558
9580
  },
8559
9581
  human: `skipped task ${taskId}: ${reason}`
8560
9582
  };
@@ -8562,6 +9584,32 @@ function skippedDrainTask(task, event, reason) {
8562
9584
  function isSkippableDrainRouteError(message) {
8563
9585
  return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
8564
9586
  }
9587
+ function todosMutationSummary(result) {
9588
+ return {
9589
+ ok: result.ok,
9590
+ status: result.status,
9591
+ error: result.ok ? undefined : redact(result.stderr || result.error || "todos mutation failed", 320)
9592
+ };
9593
+ }
9594
+ function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
9595
+ const taskId = taskField(task, ["id", "task_id", "taskId"]);
9596
+ if (!taskId)
9597
+ return { attempted: false, reason: "task id missing" };
9598
+ 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.`;
9599
+ const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
9600
+ const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
9601
+ const untagResult = runLocalCommand("todos", ["--project", todosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
9602
+ const ok = commentResult.ok && tagResult.ok && untagResult.ok;
9603
+ return {
9604
+ ok,
9605
+ attempted: true,
9606
+ taskId,
9607
+ error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
9608
+ comment: todosMutationSummary(commentResult),
9609
+ tagNoAuto: todosMutationSummary(tagResult),
9610
+ untagAutoRoute: todosMutationSummary(untagResult)
9611
+ };
9612
+ }
8565
9613
  function generatedRouteSandboxPreflight(workflow) {
8566
9614
  const checks = [];
8567
9615
  for (const step of workflow.steps) {
@@ -8687,6 +9735,28 @@ var TODOS_TASK_ROUTE_TEMPLATE_IDS = new Set([
8687
9735
  TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
8688
9736
  TASK_LIFECYCLE_TEMPLATE_ID
8689
9737
  ]);
9738
+ var UNCLEARED_ROUTE_WORK_ITEM_STATUSES = new Set([
9739
+ "admitted",
9740
+ "running",
9741
+ "succeeded",
9742
+ "failed",
9743
+ "dead_letter",
9744
+ "cancelled"
9745
+ ]);
9746
+ function isUnclearedRouteWorkItem(item) {
9747
+ return UNCLEARED_ROUTE_WORK_ITEM_STATUSES.has(item.status);
9748
+ }
9749
+ function isExistingGitProjectPath(path) {
9750
+ const result = spawnSync5("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
9751
+ return result.status === 0;
9752
+ }
9753
+ function validateRequiredRouteWorktreeProjectPath(opts, projectPath) {
9754
+ if ((opts.worktreeMode ?? "auto") !== "required")
9755
+ return;
9756
+ if (!isExistingGitProjectPath(projectPath)) {
9757
+ throw new Error(`worktreeMode=required but projectPath is not an existing git repository: ${projectPath}`);
9758
+ }
9759
+ }
8690
9760
  function todosTaskRouteTemplateId(opts) {
8691
9761
  const id = (opts.template ?? TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).trim();
8692
9762
  if (!TODOS_TASK_ROUTE_TEMPLATE_IDS.has(id)) {
@@ -8695,7 +9765,7 @@ function todosTaskRouteTemplateId(opts) {
8695
9765
  return id;
8696
9766
  }
8697
9767
  async function readEventEnvelopeInput(opts = {}) {
8698
- const raw = opts.eventJson ?? (opts.eventFile ? readFileSync4(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
9768
+ const raw = opts.eventJson ?? (opts.eventFile ? readFileSync5(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
8699
9769
  const event = JSON.parse(raw);
8700
9770
  if (!event || typeof event !== "object" || Array.isArray(event))
8701
9771
  throw new Error("event JSON must be an object");
@@ -8748,8 +9818,8 @@ function routeTodosTaskEvent(event, opts) {
8748
9818
  const store2 = new Store;
8749
9819
  try {
8750
9820
  const existingItem = store2.findWorkflowWorkItem("todos-task", idempotencyKey);
8751
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8752
- const existingLoop = store2.getLoop(existingItem.loopId);
9821
+ if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
9822
+ const existingLoop = existingItem.loopId ? store2.getLoop(existingItem.loopId) : undefined;
8753
9823
  const existingWorkflow = existingItem.workflowId ? store2.getWorkflow(existingItem.workflowId) : undefined;
8754
9824
  const existingInvocation = store2.getWorkflowInvocation(existingItem.invocationId);
8755
9825
  return {
@@ -8771,12 +9841,27 @@ function routeTodosTaskEvent(event, opts) {
8771
9841
  store2.close();
8772
9842
  }
8773
9843
  }
8774
- const provider = opts.provider ?? "codewith";
8775
- if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
8776
- throw new Error("unsupported provider");
9844
+ validateRequiredRouteWorktreeProjectPath(opts, projectPath);
9845
+ const prReviewRouting = prReviewRoutingDecision(data, metadata, opts);
9846
+ if (prReviewRouting.required && !prReviewRouting.allowed) {
9847
+ return {
9848
+ kind: "skipped",
9849
+ value: {
9850
+ skipped: true,
9851
+ reason: prReviewRouting.reason,
9852
+ event,
9853
+ taskId,
9854
+ routeError: true,
9855
+ prReviewRouting
9856
+ },
9857
+ human: `skipped task ${taskId}: ${prReviewRouting.reason}`
9858
+ };
9859
+ }
9860
+ const providerRouting = resolveProviderRouting(data, metadata, opts);
9861
+ const provider = providerRouting.provider;
8777
9862
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
8778
9863
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
8779
- const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
9864
+ const authProfile = providerAuthProfileFromOpts({ authProfile: providerRouting.authProfile }, provider);
8780
9865
  const templateId = todosTaskRouteTemplateId(opts);
8781
9866
  const workflowInput = {
8782
9867
  taskId,
@@ -8787,13 +9872,13 @@ function routeTodosTaskEvent(event, opts) {
8787
9872
  projectGroup,
8788
9873
  provider,
8789
9874
  authProfile,
8790
- authProfilePool: splitList(opts.authProfilePool),
9875
+ authProfilePool: providerRouting.authProfilePool,
8791
9876
  triageAuthProfile: opts.triageAuthProfile,
8792
9877
  plannerAuthProfile: opts.plannerAuthProfile,
8793
9878
  workerAuthProfile: opts.workerAuthProfile,
8794
9879
  verifierAuthProfile: opts.verifierAuthProfile,
8795
- account: accountFromOpts(opts),
8796
- accountPool: accountPoolFromOpts(opts),
9880
+ account: providerRouting.account,
9881
+ accountPool: providerRouting.accountPool,
8797
9882
  triageAccount: roleAccountFromOpts(opts, opts.triageAccount),
8798
9883
  plannerAccount: roleAccountFromOpts(opts, opts.plannerAccount),
8799
9884
  workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
@@ -8803,12 +9888,14 @@ function routeTodosTaskEvent(event, opts) {
8803
9888
  agent: opts.agent,
8804
9889
  addDirs: listFromRepeatedOpts(opts.addDir),
8805
9890
  timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
9891
+ verifierIdleTimeoutMs: idleTimeoutDuration(opts.verifierIdleTimeout, "--verifier-idle-timeout"),
8806
9892
  permissionMode,
8807
9893
  sandbox,
8808
9894
  manualBreakGlass: Boolean(opts.manualBreakGlass),
8809
9895
  worktreeMode: opts.worktreeMode ?? "auto",
8810
9896
  worktreeRoot: opts.worktreeRoot,
8811
9897
  worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
9898
+ prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
8812
9899
  eventId: event.id,
8813
9900
  eventType: event.type,
8814
9901
  todosProjectPath: opts.todosProject
@@ -8844,7 +9931,10 @@ function routeTodosTaskEvent(event, opts) {
8844
9931
  worktreePolicy: opts.worktreeMode ?? "auto",
8845
9932
  permissions: permissionMode,
8846
9933
  manualBreakGlass: Boolean(opts.manualBreakGlass),
8847
- accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
9934
+ prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
9935
+ accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
9936
+ providerRouting: providerRoutingPublic(providerRouting),
9937
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
8848
9938
  concurrencyGroup: projectGroup ?? routeProjectPath
8849
9939
  },
8850
9940
  outputPolicy: {
@@ -8883,7 +9973,7 @@ function routeTodosTaskEvent(event, opts) {
8883
9973
  }, {}) : undefined;
8884
9974
  return {
8885
9975
  kind: "created",
8886
- value: { deduped: false, idempotencyKey, event, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
9976
+ 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
9977
  human: `dry-run ${loopName}`
8888
9978
  };
8889
9979
  }
@@ -8899,8 +9989,8 @@ function routeTodosTaskEvent(event, opts) {
8899
9989
  const outcome = store.writeTransaction(() => {
8900
9990
  const invocation = store.createWorkflowInvocation(invocationInput);
8901
9991
  const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
8902
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8903
- const existingLoop = store.getLoop(existingItem.loopId);
9992
+ if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
9993
+ const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
8904
9994
  const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
8905
9995
  return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
8906
9996
  }
@@ -8911,6 +10001,11 @@ function routeTodosTaskEvent(event, opts) {
8911
10001
  status: throttle && !throttle.allowed ? "deferred" : "queued",
8912
10002
  lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
8913
10003
  });
10004
+ const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
10005
+ previousWorkItemId: workItem.id,
10006
+ previousAttempts: workItem.attempts,
10007
+ reason: workItem.lastReason
10008
+ } : undefined;
8914
10009
  const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
8915
10010
  if (throttle && !throttle.allowed)
8916
10011
  return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
@@ -8927,7 +10022,15 @@ function routeTodosTaskEvent(event, opts) {
8927
10022
  }
8928
10023
  });
8929
10024
  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 };
10025
+ return {
10026
+ kind: "created",
10027
+ invocation: refreshedInvocation,
10028
+ workItem: admitted,
10029
+ workflow,
10030
+ loop,
10031
+ throttle,
10032
+ requeue: requeue ? { ...requeue, attempt: admitted.attempts, newWorkflowId: workflow.id, newLoopId: loop.id } : undefined
10033
+ };
8931
10034
  });
8932
10035
  if (outcome.kind === "deduped") {
8933
10036
  return {
@@ -8956,6 +10059,8 @@ function routeTodosTaskEvent(event, opts) {
8956
10059
  event,
8957
10060
  invocation: publicWorkflowInvocation(outcome.invocation),
8958
10061
  workItem: publicWorkflowWorkItem(outcome.workItem),
10062
+ providerRouting: providerRoutingPublic(providerRouting),
10063
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
8959
10064
  throttle: outcome.throttle,
8960
10065
  workflow: workflowBody,
8961
10066
  loop: loopInput
@@ -8971,8 +10076,11 @@ function routeTodosTaskEvent(event, opts) {
8971
10076
  event,
8972
10077
  invocation: publicWorkflowInvocation(outcome.invocation),
8973
10078
  workItem: publicWorkflowWorkItem(outcome.workItem),
10079
+ providerRouting: providerRoutingPublic(providerRouting),
10080
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
8974
10081
  workflow: publicWorkflow(outcome.workflow),
8975
10082
  loop: publicLoop(outcome.loop),
10083
+ requeue: outcome.requeue,
8976
10084
  throttle: outcome.throttle,
8977
10085
  sandboxPreflight,
8978
10086
  preflight
@@ -8996,12 +10104,11 @@ function routeGenericEvent(event, opts) {
8996
10104
  const workflowName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:workflow`;
8997
10105
  const loopName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:run`;
8998
10106
  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");
10107
+ const providerRouting = resolveProviderRouting(data, metadata, opts);
10108
+ const provider = providerRouting.provider;
9002
10109
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
9003
10110
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
9004
- const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
10111
+ const authProfile = providerAuthProfileFromOpts({ authProfile: providerRouting.authProfile }, provider);
9005
10112
  let workflowBody = renderEventWorkerVerifierWorkflow({
9006
10113
  eventId: event.id,
9007
10114
  eventType: event.type,
@@ -9014,11 +10121,11 @@ function routeGenericEvent(event, opts) {
9014
10121
  projectGroup,
9015
10122
  provider,
9016
10123
  authProfile,
9017
- authProfilePool: splitList(opts.authProfilePool),
10124
+ authProfilePool: providerRouting.authProfilePool,
9018
10125
  workerAuthProfile: opts.workerAuthProfile,
9019
10126
  verifierAuthProfile: opts.verifierAuthProfile,
9020
- account: accountFromOpts(opts),
9021
- accountPool: accountPoolFromOpts(opts),
10127
+ account: providerRouting.account,
10128
+ accountPool: providerRouting.accountPool,
9022
10129
  workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
9023
10130
  verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
9024
10131
  model: opts.model,
@@ -9026,6 +10133,7 @@ function routeGenericEvent(event, opts) {
9026
10133
  agent: opts.agent,
9027
10134
  addDirs: listFromRepeatedOpts(opts.addDir),
9028
10135
  timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
10136
+ verifierIdleTimeoutMs: idleTimeoutDuration(opts.verifierIdleTimeout, "--verifier-idle-timeout"),
9029
10137
  permissionMode,
9030
10138
  sandbox,
9031
10139
  manualBreakGlass: Boolean(opts.manualBreakGlass),
@@ -9041,6 +10149,7 @@ function routeGenericEvent(event, opts) {
9041
10149
  event: event.id
9042
10150
  });
9043
10151
  const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
10152
+ const hasExplicitRoleAccount = Boolean(opts.workerAuthProfile || opts.verifierAuthProfile || opts.workerAccount || opts.verifierAccount);
9044
10153
  const invocationInput = {
9045
10154
  templateId: "event-worker-verifier",
9046
10155
  sourceRef: {
@@ -9062,7 +10171,8 @@ function routeGenericEvent(event, opts) {
9062
10171
  worktreePolicy: opts.worktreeMode ?? "auto",
9063
10172
  permissions: permissionMode,
9064
10173
  manualBreakGlass: Boolean(opts.manualBreakGlass),
9065
- accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
10174
+ accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
10175
+ providerRouting: providerRoutingPublic(providerRouting),
9066
10176
  concurrencyGroup: projectGroup ?? routeProjectPath
9067
10177
  },
9068
10178
  outputPolicy: {
@@ -9101,7 +10211,7 @@ function routeGenericEvent(event, opts) {
9101
10211
  }, {}) : undefined;
9102
10212
  return {
9103
10213
  kind: "created",
9104
- value: { event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
10214
+ value: { event, idempotencyKey, providerRouting: providerRoutingPublic(providerRouting), invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
9105
10215
  human: `dry-run ${loopName}`
9106
10216
  };
9107
10217
  }
@@ -9117,8 +10227,8 @@ function routeGenericEvent(event, opts) {
9117
10227
  const outcome = store.writeTransaction(() => {
9118
10228
  const invocation = store.createWorkflowInvocation(invocationInput);
9119
10229
  const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
9120
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
9121
- const existingLoop = store.getLoop(existingItem.loopId);
10230
+ if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
10231
+ const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
9122
10232
  const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
9123
10233
  return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
9124
10234
  }
@@ -9129,6 +10239,11 @@ function routeGenericEvent(event, opts) {
9129
10239
  status: throttle && !throttle.allowed ? "deferred" : "queued",
9130
10240
  lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
9131
10241
  });
10242
+ const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
10243
+ previousWorkItemId: workItem.id,
10244
+ previousAttempts: workItem.attempts,
10245
+ reason: workItem.lastReason
10246
+ } : undefined;
9132
10247
  const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
9133
10248
  if (throttle && !throttle.allowed)
9134
10249
  return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
@@ -9145,7 +10260,15 @@ function routeGenericEvent(event, opts) {
9145
10260
  }
9146
10261
  });
9147
10262
  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 };
10263
+ return {
10264
+ kind: "created",
10265
+ invocation: refreshedInvocation,
10266
+ workItem: admitted,
10267
+ workflow,
10268
+ loop,
10269
+ throttle,
10270
+ requeue: requeue ? { ...requeue, attempt: admitted.attempts, newWorkflowId: workflow.id, newLoopId: loop.id } : undefined
10271
+ };
9149
10272
  });
9150
10273
  if (outcome.kind === "deduped") {
9151
10274
  return {
@@ -9155,6 +10278,7 @@ function routeGenericEvent(event, opts) {
9155
10278
  idempotencyKey,
9156
10279
  dedupedBy: "work-item",
9157
10280
  event,
10281
+ providerRouting: providerRoutingPublic(providerRouting),
9158
10282
  invocation: publicWorkflowInvocation(outcome.invocation),
9159
10283
  workItem: publicWorkflowWorkItem(outcome.existingItem),
9160
10284
  workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
@@ -9172,6 +10296,7 @@ function routeGenericEvent(event, opts) {
9172
10296
  reason: outcome.throttle.reason,
9173
10297
  idempotencyKey,
9174
10298
  event,
10299
+ providerRouting: providerRoutingPublic(providerRouting),
9175
10300
  invocation: publicWorkflowInvocation(outcome.invocation),
9176
10301
  workItem: publicWorkflowWorkItem(outcome.workItem),
9177
10302
  throttle: outcome.throttle,
@@ -9187,10 +10312,12 @@ function routeGenericEvent(event, opts) {
9187
10312
  deduped: false,
9188
10313
  idempotencyKey,
9189
10314
  event,
10315
+ providerRouting: providerRoutingPublic(providerRouting),
9190
10316
  invocation: publicWorkflowInvocation(outcome.invocation),
9191
10317
  workItem: publicWorkflowWorkItem(outcome.workItem),
9192
10318
  workflow: publicWorkflow(outcome.workflow),
9193
10319
  loop: publicLoop(outcome.loop),
10320
+ requeue: outcome.requeue,
9194
10321
  throttle: outcome.throttle,
9195
10322
  sandboxPreflight,
9196
10323
  preflight
@@ -9221,14 +10348,14 @@ function taskDescriptionProjectPath(task) {
9221
10348
  return match?.[1]?.trim();
9222
10349
  }
9223
10350
  function taskProjectPath(task) {
9224
- const metadata = objectField(task.metadata) ?? {};
10351
+ const metadata = objectField2(task.metadata) ?? {};
9225
10352
  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
10353
  }
9227
10354
  function taskDrainEvent(task) {
9228
10355
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
9229
10356
  if (!taskId)
9230
10357
  throw new Error("todos ready returned a task without an id");
9231
- const metadata = objectField(task.metadata) ?? {};
10358
+ const metadata = objectField2(task.metadata) ?? {};
9232
10359
  const workingDir = taskProjectPath(task);
9233
10360
  const data = {
9234
10361
  ...task,
@@ -9236,7 +10363,7 @@ function taskDrainEvent(task) {
9236
10363
  title: taskField(task, ["title"]),
9237
10364
  description: taskField(task, ["description", "body"]),
9238
10365
  status: taskField(task, ["status"]),
9239
- tags: tagsFromValue(task.tags),
10366
+ tags: tagsFromValue2(task.tags),
9240
10367
  metadata
9241
10368
  };
9242
10369
  if (workingDir) {
@@ -9264,10 +10391,12 @@ function taskDrainEvent(task) {
9264
10391
  }
9265
10392
  function compactDrainResult(result) {
9266
10393
  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);
10394
+ const event = objectField2(value.event);
10395
+ const loop = objectField2(value.loop);
10396
+ const workflow = objectField2(value.workflow);
10397
+ const throttle = objectField2(value.throttle);
10398
+ const requeue = objectField2(value.requeue);
10399
+ const providerRouting = objectField2(value.providerRouting);
9271
10400
  return {
9272
10401
  kind: result.kind,
9273
10402
  taskId: event?.subject,
@@ -9278,6 +10407,8 @@ function compactDrainResult(result) {
9278
10407
  loopName: stringField(loop?.name),
9279
10408
  workflowId: stringField(workflow?.id),
9280
10409
  workflowName: stringField(workflow?.name),
10410
+ providerRouting,
10411
+ requeue,
9281
10412
  queuedAtSource: value.queuedAtSource
9282
10413
  };
9283
10414
  }
@@ -9324,7 +10455,7 @@ function taskMatchesDrainFilters(task, filters) {
9324
10455
  return false;
9325
10456
  }
9326
10457
  if (filters.tags.length) {
9327
- const taskTags = new Set(tagsFromValue(task.tags));
10458
+ const taskTags = new Set(tagsFromValue2(task.tags));
9328
10459
  for (const tag of filters.tags) {
9329
10460
  if (!taskTags.has(tag))
9330
10461
  return false;
@@ -9456,10 +10587,10 @@ var events = program.command("events").description("handle Hasna event envelopes
9456
10587
  var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
9457
10588
  var goal = program.command("goal").description("inspect goal runs");
9458
10589
  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");
10590
+ 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
10591
  }
9461
10592
  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");
10593
+ 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
10594
  if (opts.includeDryRun ?? true) {
9464
10595
  configured = configured.option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
9465
10596
  }
@@ -9494,6 +10625,8 @@ function routeDrainArgs(opts) {
9494
10625
  addBool("--compact", opts.compact);
9495
10626
  add("--template", opts.template);
9496
10627
  add("--provider", opts.provider);
10628
+ for (const rule of opts.providerRule ?? [])
10629
+ add("--provider-rule", rule);
9497
10630
  add("--auth-profile", opts.authProfile);
9498
10631
  add("--auth-profile-pool", opts.authProfilePool);
9499
10632
  add("--triage-auth-profile", opts.triageAuthProfile);
@@ -9513,6 +10646,7 @@ function routeDrainArgs(opts) {
9513
10646
  for (const dir of listFromRepeatedOpts(opts.addDir) ?? [])
9514
10647
  add("--add-dir", dir);
9515
10648
  add("--timeout", opts.timeout);
10649
+ add("--verifier-idle-timeout", opts.verifierIdleTimeout);
9516
10650
  add("--permission-mode", opts.permissionMode);
9517
10651
  add("--sandbox", opts.sandbox);
9518
10652
  addBool("--manual-break-glass", opts.manualBreakGlass);
@@ -9524,6 +10658,9 @@ function routeDrainArgs(opts) {
9524
10658
  add("--worktree-mode", opts.worktreeMode);
9525
10659
  add("--worktree-root", opts.worktreeRoot);
9526
10660
  add("--worktree-branch-prefix", opts.worktreeBranchPrefix);
10661
+ addBool("--pr-handoff", opts.prHandoff);
10662
+ add("--github-reviewer", opts.githubReviewer);
10663
+ add("--github-reviewer-pool", opts.githubReviewerPool);
9527
10664
  add("--name-prefix", opts.namePrefix);
9528
10665
  addBool("--preflight", opts.preflight);
9529
10666
  return args;
@@ -9559,7 +10696,8 @@ function drainTodosTaskRoutes(opts) {
9559
10696
  const message = error instanceof Error ? error.message : String(error);
9560
10697
  if (!isSkippableDrainRouteError(message))
9561
10698
  throw error;
9562
- result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed");
10699
+ const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
10700
+ result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
9563
10701
  }
9564
10702
  results.push(result);
9565
10703
  if (result.kind === "created" && !opts.dryRun)
@@ -9770,6 +10908,18 @@ routes.command("show <id>").description("show one admission work item").action((
9770
10908
  store.close();
9771
10909
  }
9772
10910
  });
10911
+ 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) => {
10912
+ const store = new Store;
10913
+ try {
10914
+ const reason = stringField(opts.reason);
10915
+ if (!reason)
10916
+ throw new Error("routes requeue requires --reason <text>");
10917
+ const item = store.requeueWorkflowWorkItem(id, { reason });
10918
+ print(publicWorkflowWorkItem(item), `requeued route work item ${item.id} (${item.routeKey})`);
10919
+ } finally {
10920
+ store.close();
10921
+ }
10922
+ });
9773
10923
  routes.command("invocations").description("list workflow invocations").option("--limit <n>", "maximum rows", "50").action((opts) => {
9774
10924
  const store = new Store;
9775
10925
  try {
@@ -9824,7 +10974,7 @@ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>")
9824
10974
  }
9825
10975
  });
9826
10976
  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) => {
10977
+ 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
10978
  const event = await readEventEnvelopeFromStdin();
9829
10979
  const result = routeTodosTaskEvent(event, opts);
9830
10980
  print(result.value, result.human);
@@ -9833,7 +10983,7 @@ var eventsDrain = events.command("drain").description("drain durable source queu
9833
10983
  addTodosDrainOptions(eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")).action((opts) => {
9834
10984
  drainTodosTaskRoutes(opts);
9835
10985
  });
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) => {
10986
+ 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
10987
  const event = await readEventEnvelopeFromStdin();
9838
10988
  const result = routeGenericEvent(event, opts);
9839
10989
  print(result.value, result.human);
@@ -10139,7 +11289,7 @@ program.command("list").alias("ls").option("--status <status>", "filter by statu
10139
11289
  for (const loop of loops) {
10140
11290
  const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
10141
11291
  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}`);
11292
+ console.log(`${loop.id} ${loop.status.padEnd(7)} cadence=${scheduleLabel(loop.schedule)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
10143
11293
  }
10144
11294
  }
10145
11295
  } finally {
@@ -10779,11 +11929,11 @@ ${result.instructions.join(`
10779
11929
  });
10780
11930
  daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
10781
11931
  const path = daemonLogPath();
10782
- if (!existsSync5(path)) {
11932
+ if (!existsSync6(path)) {
10783
11933
  console.log("");
10784
11934
  return;
10785
11935
  }
10786
- const lines = readFileSync4(path, "utf8").trimEnd().split(`
11936
+ const lines = readFileSync5(path, "utf8").trimEnd().split(`
10787
11937
  `);
10788
11938
  console.log(lines.slice(-Number(opts.lines)).join(`
10789
11939
  `));