@hasna/loops 0.3.14 → 0.3.16

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
@@ -328,6 +328,17 @@ function optionalPositiveInteger(value, label) {
328
328
  throw new Error(`${label} must be a positive integer`);
329
329
  return value;
330
330
  }
331
+ function optionalStringArray(value, label) {
332
+ if (value === undefined)
333
+ return;
334
+ if (!Array.isArray(value))
335
+ throw new Error(`${label} must be an array`);
336
+ const values = value.map((entry, index) => {
337
+ assertString(entry, `${label}[${index}]`);
338
+ return entry.trim();
339
+ }).filter(Boolean);
340
+ return values.length ? values : undefined;
341
+ }
331
342
  function normalizeGoalSpec(value, label = "goal") {
332
343
  if (value === undefined)
333
344
  return;
@@ -399,6 +410,14 @@ function validateTarget(value, label) {
399
410
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
400
411
  }
401
412
  }
413
+ if (value.allowlist !== undefined) {
414
+ assertObject(value.allowlist, `${label}.allowlist`);
415
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
416
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
417
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
418
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
419
+ }
420
+ }
402
421
  return value;
403
422
  }
404
423
  throw new Error(`${label}.type must be command or agent`);
@@ -482,6 +501,8 @@ function rowToLoop(row) {
482
501
  name: row.name,
483
502
  description: row.description ?? undefined,
484
503
  status: row.status,
504
+ archivedAt: row.archived_at ?? undefined,
505
+ archivedFromStatus: row.archived_from_status ? row.archived_from_status : undefined,
485
506
  schedule: JSON.parse(row.schedule_json),
486
507
  target: JSON.parse(row.target_json),
487
508
  goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
@@ -691,6 +712,8 @@ class Store {
691
712
  name TEXT NOT NULL,
692
713
  description TEXT,
693
714
  status TEXT NOT NULL,
715
+ archived_at TEXT,
716
+ archived_from_status TEXT,
694
717
  schedule_json TEXT NOT NULL,
695
718
  target_json TEXT NOT NULL,
696
719
  goal_json TEXT,
@@ -891,6 +914,8 @@ class Store {
891
914
  `);
892
915
  this.addColumnIfMissing("loops", "machine_json", "TEXT");
893
916
  this.addColumnIfMissing("loops", "goal_json", "TEXT");
917
+ this.addColumnIfMissing("loops", "archived_at", "TEXT");
918
+ this.addColumnIfMissing("loops", "archived_from_status", "TEXT");
894
919
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
895
920
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
896
921
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
@@ -899,6 +924,7 @@ class Store {
899
924
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
900
925
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
901
926
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
927
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
902
928
  }
903
929
  addColumnIfMissing(table, column, definition) {
904
930
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -975,12 +1001,26 @@ class Store {
975
1001
  }
976
1002
  listLoops(opts = {}) {
977
1003
  const limit = opts.limit ?? 200;
978
- const rows = opts.status ? this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1004
+ let rows;
1005
+ if (opts.status && opts.archived) {
1006
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1007
+ } else if (opts.status && opts.includeArchived) {
1008
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1009
+ } else if (opts.status) {
1010
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1011
+ } else if (opts.archived) {
1012
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
1013
+ } else if (opts.includeArchived) {
1014
+ rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1015
+ } else {
1016
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1017
+ }
979
1018
  return rows.map(rowToLoop);
980
1019
  }
981
1020
  dueLoops(now) {
982
1021
  const rows = this.db.query(`SELECT * FROM loops
983
1022
  WHERE status = 'active'
1023
+ AND archived_at IS NULL
984
1024
  AND next_run_at IS NOT NULL
985
1025
  AND next_run_at <= ?
986
1026
  ORDER BY next_run_at ASC`).all(now.toISOString());
@@ -1012,6 +1052,44 @@ class Store {
1012
1052
  throw new Error(`loop not found after update: ${id}`);
1013
1053
  return after;
1014
1054
  }
1055
+ archiveLoop(idOrName) {
1056
+ const loop = this.requireLoop(idOrName);
1057
+ if (loop.archivedAt)
1058
+ return loop;
1059
+ const updated = nowIso();
1060
+ const archivedStatus = loop.status === "active" ? "paused" : loop.status;
1061
+ this.db.query(`UPDATE loops
1062
+ SET status=$status, archived_at=$archivedAt, archived_from_status=$archivedFromStatus, updated_at=$updated
1063
+ WHERE id=$id`).run({
1064
+ $id: loop.id,
1065
+ $status: archivedStatus,
1066
+ $archivedAt: updated,
1067
+ $archivedFromStatus: loop.status,
1068
+ $updated: updated
1069
+ });
1070
+ const archived = this.getLoop(loop.id);
1071
+ if (!archived)
1072
+ throw new Error(`loop not found after archive: ${loop.id}`);
1073
+ return archived;
1074
+ }
1075
+ unarchiveLoop(idOrName) {
1076
+ const loop = this.requireLoop(idOrName);
1077
+ if (!loop.archivedAt)
1078
+ return loop;
1079
+ const updated = nowIso();
1080
+ const restoredStatus = loop.archivedFromStatus ?? loop.status;
1081
+ this.db.query(`UPDATE loops
1082
+ SET status=$status, archived_at=NULL, archived_from_status=NULL, updated_at=$updated
1083
+ WHERE id=$id`).run({
1084
+ $id: loop.id,
1085
+ $status: restoredStatus,
1086
+ $updated: updated
1087
+ });
1088
+ const unarchived = this.getLoop(loop.id);
1089
+ if (!unarchived)
1090
+ throw new Error(`loop not found after unarchive: ${loop.id}`);
1091
+ return unarchived;
1092
+ }
1015
1093
  deleteLoop(idOrName) {
1016
1094
  const loop = this.requireLoop(idOrName);
1017
1095
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
@@ -1786,10 +1864,16 @@ class Store {
1786
1864
  }
1787
1865
  claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1788
1866
  const startedAt = now.toISOString();
1789
- const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1790
1867
  this.db.exec("BEGIN IMMEDIATE");
1791
1868
  try {
1792
1869
  this.assertDaemonLeaseFence(opts, startedAt);
1870
+ const currentLoop = this.getLoop(loop.id);
1871
+ if (!currentLoop || currentLoop.archivedAt) {
1872
+ this.db.exec("COMMIT");
1873
+ return;
1874
+ }
1875
+ loop = currentLoop;
1876
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1793
1877
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1794
1878
  if (existing) {
1795
1879
  if (existing.status === "running") {
@@ -2007,7 +2091,7 @@ class Store {
2007
2091
  return recovered;
2008
2092
  }
2009
2093
  expireLoops(now = new Date, opts = {}) {
2010
- const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2094
+ const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND archived_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2011
2095
  const expired = [];
2012
2096
  for (const row of rows) {
2013
2097
  const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
@@ -2016,8 +2100,21 @@ class Store {
2016
2100
  }
2017
2101
  return expired;
2018
2102
  }
2019
- countLoops(status) {
2020
- const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2103
+ countLoops(status, opts = {}) {
2104
+ let row;
2105
+ if (status && opts.archived) {
2106
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NOT NULL").get(status);
2107
+ } else if (status && opts.includeArchived) {
2108
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status);
2109
+ } else if (status) {
2110
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NULL").get(status);
2111
+ } else if (opts.archived) {
2112
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NOT NULL").get();
2113
+ } else if (opts.includeArchived) {
2114
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2115
+ } else {
2116
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NULL").get();
2117
+ }
2021
2118
  return row?.count ?? 0;
2022
2119
  }
2023
2120
  countRuns(status) {
@@ -2074,7 +2171,7 @@ class Store {
2074
2171
  }
2075
2172
 
2076
2173
  // src/cli/index.ts
2077
- import { createHash } from "crypto";
2174
+ import { createHash as createHash2 } from "crypto";
2078
2175
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2079
2176
  import { Command } from "commander";
2080
2177
 
@@ -2493,6 +2590,16 @@ function metadataEnv(metadata) {
2493
2590
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2494
2591
  return env;
2495
2592
  }
2593
+ function allowlistEnv(allowlist) {
2594
+ const env = {};
2595
+ if (allowlist?.tools?.length)
2596
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2597
+ if (allowlist?.commands?.length)
2598
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2599
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2600
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2601
+ return env;
2602
+ }
2496
2603
  function providerCommand(provider) {
2497
2604
  switch (provider) {
2498
2605
  case "claude":
@@ -2700,7 +2807,8 @@ function commandSpec(target) {
2700
2807
  account: agentTarget.account,
2701
2808
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2702
2809
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2703
- stdin: agentTarget.prompt
2810
+ stdin: agentTarget.prompt,
2811
+ allowlist: agentTarget.allowlist
2704
2812
  };
2705
2813
  }
2706
2814
  function executionEnv(spec, metadata, opts) {
@@ -2712,6 +2820,7 @@ function executionEnv(spec, metadata, opts) {
2712
2820
  Object.assign(env, accountEnv);
2713
2821
  }
2714
2822
  Object.assign(env, spec.env ?? {});
2823
+ Object.assign(env, allowlistEnv(spec.allowlist));
2715
2824
  env.PATH = normalizeExecutionPath(env);
2716
2825
  Object.assign(env, metadataEnv(metadata));
2717
2826
  return env;
@@ -2750,6 +2859,9 @@ function remoteBootstrapLines(spec, metadata) {
2750
2859
  continue;
2751
2860
  lines.push(`export ${key}=${shellQuote(value)}`);
2752
2861
  }
2862
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2863
+ lines.push(`export ${key}=${shellQuote(value)}`);
2864
+ }
2753
2865
  return lines;
2754
2866
  }
2755
2867
  function remoteScript(spec, metadata) {
@@ -3703,12 +3815,16 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
3703
3815
 
3704
3816
  // src/lib/scheduler.ts
3705
3817
  function manualRunScheduledFor(loop, now = new Date) {
3818
+ if (loop.archivedAt)
3819
+ return now.toISOString();
3706
3820
  if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
3707
3821
  return loop.retryScheduledFor ?? loop.nextRunAt;
3708
3822
  }
3709
3823
  return now.toISOString();
3710
3824
  }
3711
3825
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3826
+ if (loop.archivedAt)
3827
+ return false;
3712
3828
  if (loop.status !== "active")
3713
3829
  return false;
3714
3830
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3716,6 +3832,8 @@ function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3716
3832
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
3717
3833
  }
3718
3834
  function manualRunSource(loop, scheduledFor, now = new Date) {
3835
+ if (loop.archivedAt)
3836
+ return "ad_hoc";
3719
3837
  if (loop.status !== "active")
3720
3838
  return "ad_hoc";
3721
3839
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3734,7 +3852,7 @@ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
3734
3852
  if (run.status === "running")
3735
3853
  return;
3736
3854
  const current = store.getLoop(loop.id);
3737
- if (!current || current.status !== "active")
3855
+ if (!current || current.status !== "active" || current.archivedAt)
3738
3856
  return;
3739
3857
  if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
3740
3858
  return;
@@ -4069,7 +4187,8 @@ function daemonStatus(store, path = pidFilePath()) {
4069
4187
  active: store.countLoops("active"),
4070
4188
  paused: store.countLoops("paused"),
4071
4189
  stopped: store.countLoops("stopped"),
4072
- expired: store.countLoops("expired")
4190
+ expired: store.countLoops("expired"),
4191
+ archived: store.countLoops(undefined, { archived: true })
4073
4192
  },
4074
4193
  runs: {
4075
4194
  total: store.countRuns(),
@@ -4492,10 +4611,220 @@ function runDoctor(store) {
4492
4611
  checks
4493
4612
  };
4494
4613
  }
4614
+
4615
+ // src/lib/health.ts
4616
+ import { createHash } from "crypto";
4617
+ var EVIDENCE_CHARS = 2000;
4618
+ var CLASSIFICATIONS = [
4619
+ "rate_limit",
4620
+ "auth",
4621
+ "model_not_found",
4622
+ "context_length",
4623
+ "schema_response_format",
4624
+ "node_init",
4625
+ "timeout",
4626
+ "sigsegv",
4627
+ "skipped_previous_active",
4628
+ "unknown"
4629
+ ];
4630
+ function bounded(value, limit = EVIDENCE_CHARS) {
4631
+ if (!value)
4632
+ return;
4633
+ if (value.length <= limit)
4634
+ return value;
4635
+ return `${value.slice(0, limit)}
4636
+ [truncated ${value.length - limit} chars]`;
4637
+ }
4638
+ function searchableText(run) {
4639
+ return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
4640
+ `).toLowerCase();
4641
+ }
4642
+ function stableFingerprint(parts) {
4643
+ return createHash("sha256").update(parts.join(`
4644
+ `)).digest("hex").slice(0, 16);
4645
+ }
4646
+ function healthRun(run) {
4647
+ return {
4648
+ ...run,
4649
+ error: bounded(run.error),
4650
+ stdout: bounded(run.stdout),
4651
+ stderr: bounded(run.stderr)
4652
+ };
4653
+ }
4654
+ function classifyRunFailure(run) {
4655
+ if (run.status === "succeeded" || run.status === "running")
4656
+ return;
4657
+ const text = searchableText(run);
4658
+ let classification = "unknown";
4659
+ if (run.status === "timed_out")
4660
+ classification = "timeout";
4661
+ else if (run.status === "skipped" && /previous run still active/.test(text))
4662
+ classification = "skipped_previous_active";
4663
+ else if (/rate limit|too many requests|429\b|quota exceeded/.test(text))
4664
+ classification = "rate_limit";
4665
+ else if (/unauthorized|authentication|auth\b|api key|invalid token|permission denied|401\b|403\b/.test(text))
4666
+ classification = "auth";
4667
+ else if (/model .*not found|model_not_found|unknown model|invalid model|404.*model/.test(text))
4668
+ classification = "model_not_found";
4669
+ else if (/context length|context_length|context window|maximum context|token limit|too many tokens/.test(text))
4670
+ classification = "context_length";
4671
+ else if (/response_format|json schema|schema validation|invalid schema|structured output/.test(text))
4672
+ classification = "schema_response_format";
4673
+ else if (/cannot find module|module not found|node:internal|bun: command not found|node: command not found|npm err!|err_module_not_found/.test(text))
4674
+ classification = "node_init";
4675
+ else if (/sigsegv|segmentation fault|signal 11/.test(text))
4676
+ classification = "sigsegv";
4677
+ return {
4678
+ classification,
4679
+ fingerprint: stableFingerprint([
4680
+ run.loopId,
4681
+ run.loopName,
4682
+ run.status,
4683
+ classification,
4684
+ String(run.exitCode ?? ""),
4685
+ (run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
4686
+ ]),
4687
+ evidence: {
4688
+ error: bounded(run.error),
4689
+ stdout: bounded(run.stdout),
4690
+ stderr: bounded(run.stderr),
4691
+ exitCode: run.exitCode
4692
+ }
4693
+ };
4694
+ }
4695
+ function targetRoute(loop) {
4696
+ if (loop.target.type === "agent") {
4697
+ return {
4698
+ source: "openloops",
4699
+ kind: "loop_expectation",
4700
+ loopId: loop.id,
4701
+ loopName: loop.name,
4702
+ cwd: loop.target.cwd,
4703
+ provider: loop.target.provider
4704
+ };
4705
+ }
4706
+ if (loop.target.type === "command") {
4707
+ return {
4708
+ source: "openloops",
4709
+ kind: "loop_expectation",
4710
+ loopId: loop.id,
4711
+ loopName: loop.name,
4712
+ cwd: loop.target.cwd
4713
+ };
4714
+ }
4715
+ return {
4716
+ source: "openloops",
4717
+ kind: "loop_expectation",
4718
+ loopId: loop.id,
4719
+ loopName: loop.name
4720
+ };
4721
+ }
4722
+ function recommendedTask(loop, run, failure, route) {
4723
+ const title = `BUG: open-loops loop failure - ${loop.name}`;
4724
+ const description = [
4725
+ `OpenLoops expectation failed for loop ${loop.name} (${loop.id}).`,
4726
+ `Run: ${run.id}`,
4727
+ `Status: ${run.status}`,
4728
+ `Classification: ${failure.classification}`,
4729
+ `Fingerprint: ${failure.fingerprint}`,
4730
+ route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4731
+ route.provider ? `Provider: ${route.provider}` : undefined,
4732
+ failure.evidence.error ? `Error:
4733
+ ${failure.evidence.error}` : undefined,
4734
+ failure.evidence.stderr ? `Stderr:
4735
+ ${failure.evidence.stderr}` : undefined
4736
+ ].filter(Boolean).join(`
4737
+
4738
+ `);
4739
+ const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
4740
+ const tags = ["bug", "openloops", "loop-health", failure.classification];
4741
+ const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
4742
+ return {
4743
+ title,
4744
+ description,
4745
+ priority,
4746
+ tags,
4747
+ dedupeKey,
4748
+ search: { query: dedupeKey },
4749
+ compatibilityFallback: {
4750
+ search: ["todos", "search", dedupeKey, "--json"],
4751
+ add: ["todos", "add", title, "--description", description, "--tag", tags.join(","), "--priority", priority],
4752
+ comment: ["todos", "comment", "<task-id>", description]
4753
+ },
4754
+ futureNativeUpsert: {
4755
+ command: "todos upsert",
4756
+ fields: {
4757
+ title,
4758
+ description,
4759
+ priority,
4760
+ tags,
4761
+ dedupeKey,
4762
+ routeSource: route.source,
4763
+ routeKind: route.kind,
4764
+ routeLoopId: route.loopId,
4765
+ routeLoopName: route.loopName
4766
+ }
4767
+ }
4768
+ };
4769
+ }
4770
+ function expectationForLoop(store, loop) {
4771
+ const latestRun = store.listRuns({ loopId: loop.id, limit: 1 })[0];
4772
+ const route = targetRoute(loop);
4773
+ if (!latestRun) {
4774
+ return {
4775
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4776
+ ok: true,
4777
+ check: { id: "latest-run-succeeded", status: "warn", message: "loop has no recorded runs yet" },
4778
+ route
4779
+ };
4780
+ }
4781
+ if (latestRun.status === "succeeded") {
4782
+ return {
4783
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4784
+ ok: true,
4785
+ check: { id: "latest-run-succeeded", status: "pass", message: "latest run succeeded" },
4786
+ latestRun: healthRun(latestRun),
4787
+ route
4788
+ };
4789
+ }
4790
+ const failure = classifyRunFailure(latestRun);
4791
+ return {
4792
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4793
+ ok: false,
4794
+ check: { id: "latest-run-succeeded", status: "fail", message: `latest run is ${latestRun.status}` },
4795
+ latestRun: healthRun(latestRun),
4796
+ failure,
4797
+ route,
4798
+ recommendedTask: failure ? recommendedTask(loop, latestRun, failure, route) : undefined
4799
+ };
4800
+ }
4801
+ function buildHealthReport(store, opts = {}) {
4802
+ const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4803
+ const expectations = loops.map((loop) => expectationForLoop(store, loop));
4804
+ const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4805
+ for (const expectation of expectations) {
4806
+ if (expectation.failure)
4807
+ classifications[expectation.failure.classification] += 1;
4808
+ }
4809
+ const unhealthy = expectations.filter((expectation) => !expectation.ok).length;
4810
+ const warnings = expectations.filter((expectation) => expectation.check.status === "warn").length;
4811
+ return {
4812
+ ok: unhealthy === 0,
4813
+ generatedAt: new Date().toISOString(),
4814
+ summary: {
4815
+ loops: expectations.length,
4816
+ healthy: expectations.length - unhealthy,
4817
+ unhealthy,
4818
+ warnings
4819
+ },
4820
+ classifications,
4821
+ expectations
4822
+ };
4823
+ }
4495
4824
  // package.json
4496
4825
  var package_default = {
4497
4826
  name: "@hasna/loops",
4498
- version: "0.3.14",
4827
+ version: "0.3.16",
4499
4828
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4500
4829
  type: "module",
4501
4830
  main: "dist/index.js",
@@ -4598,6 +4927,10 @@ var TEMPLATE_SUMMARIES = [
4598
4927
  { name: "projectPath", required: true, description: "Repository or project working directory." },
4599
4928
  { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
4600
4929
  { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
4930
+ { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
4931
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
4932
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
4933
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
4601
4934
  { name: "model", description: "Provider model." },
4602
4935
  { name: "variant", description: "Provider reasoning/model effort variant." },
4603
4936
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
@@ -4617,6 +4950,10 @@ var TEMPLATE_SUMMARIES = [
4617
4950
  { name: "projectPath", required: true, description: "Repository or project working directory." },
4618
4951
  { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
4619
4952
  { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
4953
+ { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
4954
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
4955
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
4956
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
4620
4957
  { name: "model", description: "Provider model." },
4621
4958
  { name: "variant", description: "Provider reasoning/model effort variant." },
4622
4959
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
@@ -4631,7 +4968,37 @@ function taskLabel(input) {
4631
4968
  const head = input.taskTitle?.trim() || input.taskId;
4632
4969
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
4633
4970
  }
4634
- function agentTarget(input, prompt) {
4971
+ function stableIndex(seed, size) {
4972
+ let hash = 2166136261;
4973
+ for (let i = 0;i < seed.length; i += 1) {
4974
+ hash ^= seed.charCodeAt(i);
4975
+ hash = Math.imul(hash, 16777619);
4976
+ }
4977
+ return Math.abs(hash >>> 0) % size;
4978
+ }
4979
+ function rolePoolValue(pool, seed, role) {
4980
+ if (!pool?.length)
4981
+ return;
4982
+ const workerIndex = stableIndex(seed, pool.length);
4983
+ if (role === "worker" || pool.length === 1)
4984
+ return pool[workerIndex];
4985
+ return pool[(workerIndex + 1) % pool.length];
4986
+ }
4987
+ function authProfileForRole(input, role, seed) {
4988
+ if (role === "worker" && input.workerAuthProfile)
4989
+ return input.workerAuthProfile;
4990
+ if (role === "verifier" && input.verifierAuthProfile)
4991
+ return input.verifierAuthProfile;
4992
+ return rolePoolValue(input.authProfilePool, seed, role) ?? input.authProfile;
4993
+ }
4994
+ function accountForRole(input, role, seed) {
4995
+ if (role === "worker" && input.workerAccount)
4996
+ return input.workerAccount;
4997
+ if (role === "verifier" && input.verifierAccount)
4998
+ return input.verifierAccount;
4999
+ return rolePoolValue(input.accountPool, seed, role) ?? input.account;
5000
+ }
5001
+ function agentTarget(input, prompt, role, seed) {
4635
5002
  const provider = input.provider ?? "codewith";
4636
5003
  const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
4637
5004
  return {
@@ -4642,11 +5009,11 @@ function agentTarget(input, prompt) {
4642
5009
  model: input.model,
4643
5010
  variant: input.variant,
4644
5011
  agent: input.agent,
4645
- authProfile: provider === "codewith" ? input.authProfile : undefined,
5012
+ authProfile: provider === "codewith" ? authProfileForRole(input, role, seed) : undefined,
4646
5013
  configIsolation: "safe",
4647
5014
  permissionMode: input.permissionMode ?? "bypass",
4648
5015
  sandbox,
4649
- account: input.account,
5016
+ account: accountForRole(input, role, seed),
4650
5017
  timeoutMs: 45 * 60000
4651
5018
  };
4652
5019
  }
@@ -4700,7 +5067,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4700
5067
  id: "worker",
4701
5068
  name: "Worker",
4702
5069
  description: "Implement the todos task and record evidence.",
4703
- target: agentTarget(input, workerPrompt),
5070
+ target: agentTarget(input, workerPrompt, "worker", input.taskId),
4704
5071
  timeoutMs: 45 * 60000
4705
5072
  },
4706
5073
  {
@@ -4708,7 +5075,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4708
5075
  name: "Verifier",
4709
5076
  description: "Adversarially verify worker output and update todos.",
4710
5077
  dependsOn: ["worker"],
4711
- target: agentTarget(input, verifierPrompt),
5078
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId),
4712
5079
  timeoutMs: 30 * 60000
4713
5080
  }
4714
5081
  ]
@@ -4764,7 +5131,7 @@ function renderEventWorkerVerifierWorkflow(input) {
4764
5131
  id: "worker",
4765
5132
  name: "Worker",
4766
5133
  description: "Handle the Hasna event and record evidence.",
4767
- target: agentTarget(input, workerPrompt),
5134
+ target: agentTarget(input, workerPrompt, "worker", `${input.eventSource}:${input.eventType}:${input.eventId}`),
4768
5135
  timeoutMs: 45 * 60000
4769
5136
  },
4770
5137
  {
@@ -4772,7 +5139,7 @@ function renderEventWorkerVerifierWorkflow(input) {
4772
5139
  name: "Verifier",
4773
5140
  description: "Adversarially verify event handling.",
4774
5141
  dependsOn: ["worker"],
4775
- target: agentTarget(input, verifierPrompt),
5142
+ target: agentTarget(input, verifierPrompt, "verifier", `${input.eventSource}:${input.eventType}:${input.eventId}`),
4776
5143
  timeoutMs: 30 * 60000
4777
5144
  }
4778
5145
  ]
@@ -4787,7 +5154,11 @@ function renderLoopTemplate(id, values) {
4787
5154
  projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
4788
5155
  provider: values.provider,
4789
5156
  authProfile: values.authProfile,
5157
+ authProfilePool: listVar(values.authProfilePool),
5158
+ workerAuthProfile: values.workerAuthProfile,
5159
+ verifierAuthProfile: values.verifierAuthProfile,
4790
5160
  account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
5161
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
4791
5162
  model: values.model,
4792
5163
  variant: values.variant,
4793
5164
  agent: values.agent,
@@ -4808,7 +5179,11 @@ function renderLoopTemplate(id, values) {
4808
5179
  projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
4809
5180
  provider: values.provider,
4810
5181
  authProfile: values.authProfile,
5182
+ authProfilePool: listVar(values.authProfilePool),
5183
+ workerAuthProfile: values.workerAuthProfile,
5184
+ verifierAuthProfile: values.verifierAuthProfile,
4811
5185
  account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
5186
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
4812
5187
  model: values.model,
4813
5188
  variant: values.variant,
4814
5189
  agent: values.agent,
@@ -4818,6 +5193,13 @@ function renderLoopTemplate(id, values) {
4818
5193
  }
4819
5194
  throw new Error(`unknown template: ${id}`);
4820
5195
  }
5196
+ function listVar(value) {
5197
+ const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
5198
+ return values?.length ? values : undefined;
5199
+ }
5200
+ function accountPoolVar(value, tool) {
5201
+ return listVar(value)?.map((profile) => ({ profile, tool }));
5202
+ }
4821
5203
 
4822
5204
  // src/cli/index.ts
4823
5205
  var program = new Command;
@@ -4933,10 +5315,32 @@ function goalFromOpts(opts) {
4933
5315
  }, "goal");
4934
5316
  }
4935
5317
  function accountFromOpts(opts) {
4936
- if (!opts.account && opts.accountTool)
4937
- throw new Error("--account-tool requires --account");
5318
+ if (!opts.account && opts.accountTool && !opts.accountPool && !opts.workerAccount && !opts.verifierAccount) {
5319
+ throw new Error("--account-tool requires --account, --account-pool, --worker-account, or --verifier-account");
5320
+ }
4938
5321
  return opts.account ? { profile: opts.account, tool: opts.accountTool } : undefined;
4939
5322
  }
5323
+ function splitList(value) {
5324
+ const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
5325
+ return values?.length ? values : undefined;
5326
+ }
5327
+ function allowlistFromOpts(opts) {
5328
+ const tools = (opts.allowTool ?? []).flatMap((entry) => splitList(entry) ?? []);
5329
+ const commands = (opts.allowCommand ?? []).flatMap((entry) => splitList(entry) ?? []);
5330
+ if (!tools.length && !commands.length)
5331
+ return;
5332
+ return {
5333
+ tools: tools.length ? tools : undefined,
5334
+ commands: commands.length ? commands : undefined,
5335
+ enforcement: "metadata_only"
5336
+ };
5337
+ }
5338
+ function accountPoolFromOpts(opts) {
5339
+ return splitList(opts.accountPool)?.map((profile) => ({ profile, tool: opts.accountTool }));
5340
+ }
5341
+ function roleAccountFromOpts(opts, profile) {
5342
+ return profile ? { profile, tool: opts.accountTool } : undefined;
5343
+ }
4940
5344
  function parseVars(values) {
4941
5345
  const vars = {};
4942
5346
  for (const value of values ?? []) {
@@ -4970,7 +5374,7 @@ function slugSegment(value, fallback = "event") {
4970
5374
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
4971
5375
  }
4972
5376
  function stableSuffix(value) {
4973
- return createHash("sha256").update(value).digest("hex").slice(0, 12);
5377
+ return createHash2("sha256").update(value).digest("hex").slice(0, 12);
4974
5378
  }
4975
5379
  function taskEventField(data, keys) {
4976
5380
  for (const key of keys) {
@@ -4996,6 +5400,85 @@ function taskEventField(data, keys) {
4996
5400
  }
4997
5401
  return;
4998
5402
  }
5403
+ function objectField(value) {
5404
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
5405
+ }
5406
+ function nestedObject(input, key) {
5407
+ return objectField(input[key]);
5408
+ }
5409
+ function taskEventRecords(data, metadata) {
5410
+ const records = [data];
5411
+ const dataTask = nestedObject(data, "task");
5412
+ if (dataTask)
5413
+ records.push(dataTask);
5414
+ const dataPayload = nestedObject(data, "payload");
5415
+ if (dataPayload) {
5416
+ records.push(dataPayload);
5417
+ const payloadTask = nestedObject(dataPayload, "task");
5418
+ if (payloadTask)
5419
+ records.push(payloadTask);
5420
+ }
5421
+ const dataMetadata = nestedObject(data, "metadata");
5422
+ if (dataMetadata)
5423
+ records.push(dataMetadata);
5424
+ records.push(metadata);
5425
+ const metadataTask = nestedObject(metadata, "task");
5426
+ if (metadataTask)
5427
+ records.push(metadataTask);
5428
+ const metadataAutomation = nestedObject(metadata, "automation");
5429
+ if (metadataAutomation)
5430
+ records.push(metadataAutomation);
5431
+ return records;
5432
+ }
5433
+ function booleanLike(value) {
5434
+ return value === true || value === "true" || value === "1" || value === 1;
5435
+ }
5436
+ function hasTruthyField(records, keys) {
5437
+ return records.some((record) => keys.some((key) => booleanLike(record[key])));
5438
+ }
5439
+ function tagsFromValue(value) {
5440
+ if (Array.isArray(value))
5441
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
5442
+ if (typeof value === "string")
5443
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
5444
+ return [];
5445
+ }
5446
+ function taskEventTags(records) {
5447
+ const tags = new Set;
5448
+ for (const record of records) {
5449
+ for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags))
5450
+ tags.add(tag);
5451
+ }
5452
+ return [...tags];
5453
+ }
5454
+ function taskRouteEligibility(data, metadata) {
5455
+ const records = taskEventRecords(data, metadata);
5456
+ const tags = taskEventTags(records);
5457
+ const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed", "allowed"]);
5458
+ if (!hasRouteOptIn)
5459
+ return { eligible: false, reason: "missing explicit route opt-in", tags };
5460
+ const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
5461
+ if (status && ["blocked", "completed", "done", "cancelled", "canceled", "failed", "archived"].includes(status)) {
5462
+ return { eligible: false, reason: `task status is not routable: ${status}`, tags };
5463
+ }
5464
+ const disallowedTags = tags.filter((tag) => ["no-auto", "manual", "manual-required", "approval-required"].includes(tag));
5465
+ if (disallowedTags.length)
5466
+ return { eligible: false, reason: `task has disallowed tag: ${disallowedTags[0]}`, tags };
5467
+ if (hasTruthyField(records, [
5468
+ "no_auto",
5469
+ "noAuto",
5470
+ "manual",
5471
+ "manual_required",
5472
+ "manualRequired",
5473
+ "requires_approval",
5474
+ "requiresApproval",
5475
+ "approval_required",
5476
+ "approvalRequired"
5477
+ ])) {
5478
+ return { eligible: false, reason: "task metadata requires manual or approval-gated handling", tags };
5479
+ }
5480
+ return { eligible: true, tags };
5481
+ }
4999
5482
  async function readEventEnvelopeFromStdin() {
5000
5483
  const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
5001
5484
  const event = JSON.parse(raw);
@@ -5068,7 +5551,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5068
5551
  store.close();
5069
5552
  }
5070
5553
  });
5071
- addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--config-isolation <mode>", "safe or none", "safe"))))).action((name, opts) => {
5554
+ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe"))))).action((name, opts) => {
5072
5555
  const provider = opts.provider;
5073
5556
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
5074
5557
  throw new Error("unsupported provider");
@@ -5091,6 +5574,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5091
5574
  configIsolation: opts.configIsolation,
5092
5575
  permissionMode: permissionModeFromOpts(opts, provider),
5093
5576
  sandbox: sandboxFromOpts(opts, provider),
5577
+ allowlist: allowlistFromOpts(opts),
5094
5578
  account: accountFromOpts(opts)
5095
5579
  };
5096
5580
  const loop = store.createLoop(baseCreateInput(name, opts, target));
@@ -5149,13 +5633,18 @@ templates.command("create-workflow <id>").description("render and store a templa
5149
5633
  }
5150
5634
  });
5151
5635
  var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
5152
- eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--account <profile>", "OpenAccounts profile name").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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5636
+ eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task 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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5153
5637
  const event = await readEventEnvelopeFromStdin();
5154
5638
  const data = eventData(event);
5155
5639
  const metadata = eventMetadata(event);
5156
5640
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
5157
5641
  if (!taskId)
5158
5642
  throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
5643
+ const eligibility = taskRouteEligibility(data, metadata);
5644
+ if (!eligibility.eligible) {
5645
+ print({ skipped: true, reason: eligibility.reason, event, taskId, eligibility }, `skipped task ${taskId}: ${eligibility.reason}`);
5646
+ return;
5647
+ }
5159
5648
  const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
5160
5649
  const taskDescription = taskEventField(data, ["description", "body"]);
5161
5650
  const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
@@ -5182,7 +5671,13 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5182
5671
  projectPath,
5183
5672
  provider,
5184
5673
  authProfile,
5674
+ authProfilePool: splitList(opts.authProfilePool),
5675
+ workerAuthProfile: opts.workerAuthProfile,
5676
+ verifierAuthProfile: opts.verifierAuthProfile,
5185
5677
  account: accountFromOpts(opts),
5678
+ accountPool: accountPoolFromOpts(opts),
5679
+ workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
5680
+ verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
5186
5681
  model: opts.model,
5187
5682
  variant: opts.variant,
5188
5683
  agent: opts.agent,
@@ -5236,7 +5731,7 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5236
5731
  store.close();
5237
5732
  }
5238
5733
  });
5239
- 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("--account <profile>", "OpenAccounts profile name").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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5734
+ 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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5240
5735
  const event = await readEventEnvelopeFromStdin();
5241
5736
  const data = eventData(event);
5242
5737
  const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
@@ -5256,7 +5751,13 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
5256
5751
  projectPath,
5257
5752
  provider,
5258
5753
  authProfile,
5754
+ authProfilePool: splitList(opts.authProfilePool),
5755
+ workerAuthProfile: opts.workerAuthProfile,
5756
+ verifierAuthProfile: opts.verifierAuthProfile,
5259
5757
  account: accountFromOpts(opts),
5758
+ accountPool: accountPoolFromOpts(opts),
5759
+ workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
5760
+ verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
5260
5761
  model: opts.model,
5261
5762
  variant: opts.variant,
5262
5763
  agent: opts.agent,
@@ -5523,16 +6024,19 @@ workflows.command("archive <idOrName>").action((idOrName) => {
5523
6024
  store.close();
5524
6025
  }
5525
6026
  });
5526
- program.command("list").alias("ls").option("--status <status>", "filter by status").action((opts) => {
6027
+ program.command("list").alias("ls").option("--status <status>", "filter by status").option("--archived", "show only archived loops").option("--all", "include archived loops").action((opts) => {
6028
+ if (opts.archived && opts.all)
6029
+ throw new Error("use either --archived or --all, not both");
5527
6030
  const store = new Store;
5528
6031
  try {
5529
- const loops = store.listLoops({ status: opts.status });
6032
+ const loops = store.listLoops({ status: opts.status, archived: opts.archived, includeArchived: opts.all });
5530
6033
  if (isJson())
5531
6034
  print(loops.map(publicLoop));
5532
6035
  else {
5533
6036
  for (const loop of loops) {
5534
6037
  const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
5535
- console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}`);
6038
+ const archive = loop.archivedAt ? ` archived=${loop.archivedAt} from=${loop.archivedFromStatus ?? "-"}` : "";
6039
+ console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
5536
6040
  }
5537
6041
  }
5538
6042
  } finally {
@@ -5565,6 +6069,44 @@ program.command("runs [idOrName]").option("--limit <n>", "limit", "50").option("
5565
6069
  store.close();
5566
6070
  }
5567
6071
  });
6072
+ program.command("expectations [idOrName]").description("evaluate deterministic loop expectations without mutating external task systems").option("--limit <n>", "maximum loops to inspect when no loop is specified", "200").option("--json", "print JSON").action((idOrName, opts) => {
6073
+ const store = new Store;
6074
+ try {
6075
+ const loops = idOrName ? [store.requireLoop(idOrName)] : store.listLoops({ limit: Number(opts.limit) });
6076
+ const values = loops.map((loop) => expectationForLoop(store, loop));
6077
+ if (isJson() || opts.json)
6078
+ console.log(JSON.stringify(idOrName ? values[0] : values, null, 2));
6079
+ else {
6080
+ for (const value of values) {
6081
+ console.log(`${value.ok ? "ok" : "fail"} ${value.loop.name} ${value.check.message}`);
6082
+ if (value.failure)
6083
+ console.log(` classification=${value.failure.classification} fingerprint=${value.failure.fingerprint}`);
6084
+ }
6085
+ }
6086
+ if (values.some((value) => !value.ok))
6087
+ process.exitCode = 1;
6088
+ } finally {
6089
+ store.close();
6090
+ }
6091
+ });
6092
+ program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
6093
+ const store = new Store;
6094
+ try {
6095
+ const report = buildHealthReport(store);
6096
+ if (isJson() || opts.json)
6097
+ console.log(JSON.stringify(report, null, 2));
6098
+ else {
6099
+ console.log(`loops=${report.summary.loops} healthy=${report.summary.healthy} unhealthy=${report.summary.unhealthy} warnings=${report.summary.warnings}`);
6100
+ for (const expectation of report.expectations.filter((entry) => !entry.ok)) {
6101
+ console.log(`fail ${expectation.loop.name} ${expectation.failure?.classification ?? "unknown"} ${expectation.failure?.fingerprint ?? "-"}`);
6102
+ }
6103
+ }
6104
+ if (!report.ok)
6105
+ process.exitCode = 1;
6106
+ } finally {
6107
+ store.close();
6108
+ }
6109
+ });
5568
6110
  program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
5569
6111
  program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
5570
6112
  program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
@@ -5572,6 +6114,8 @@ function updateStatus(idOrName, status) {
5572
6114
  const store = new Store;
5573
6115
  try {
5574
6116
  const loop = store.requireLoop(idOrName);
6117
+ if (loop.archivedAt)
6118
+ throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' first`);
5575
6119
  const updated = store.updateLoop(loop.id, { status, nextRunAt: status === "stopped" ? undefined : loop.nextRunAt });
5576
6120
  print(publicLoop(updated), `${updated.id} ${updated.status}`);
5577
6121
  } finally {
@@ -5587,10 +6131,30 @@ program.command("remove <idOrName>").alias("rm").action((idOrName) => {
5587
6131
  store.close();
5588
6132
  }
5589
6133
  });
6134
+ program.command("archive <idOrName>").description("archive a loop without deleting history").action((idOrName) => {
6135
+ const store = new Store;
6136
+ try {
6137
+ const loop = store.archiveLoop(idOrName);
6138
+ print(publicLoop(loop), `${loop.id} archived`);
6139
+ } finally {
6140
+ store.close();
6141
+ }
6142
+ });
6143
+ program.command("unarchive <idOrName>").alias("restore").description("restore an archived loop").action((idOrName) => {
6144
+ const store = new Store;
6145
+ try {
6146
+ const loop = store.unarchiveLoop(idOrName);
6147
+ print(publicLoop(loop), `${loop.id} ${loop.status}`);
6148
+ } finally {
6149
+ store.close();
6150
+ }
6151
+ });
5590
6152
  program.command("run-now <idOrName>").option("--show-output", "show stdout/stderr").action(async (idOrName, opts) => {
5591
6153
  const store = new Store;
5592
6154
  try {
5593
6155
  const loop = store.requireLoop(idOrName);
6156
+ if (loop.archivedAt)
6157
+ throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' before running it`);
5594
6158
  const runnerId = `manual:${process.pid}`;
5595
6159
  const now = new Date;
5596
6160
  let scheduledFor = manualRunScheduledFor(loop, now);