@hasna/loops 0.3.16 → 0.3.18

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/README.md CHANGED
@@ -210,7 +210,8 @@ Use `shell: true` only when you intentionally want shell parsing:
210
210
  Built-in templates turn common orchestration flows into reusable workflow JSON.
211
211
  `todos-task-worker-verifier` performs one todos task and then verifies it.
212
212
  `event-worker-verifier` handles any Hasna event envelope and then verifies the
213
- handling.
213
+ handling. `bounded-agent-worker-verifier` is for recurring bounded agent work:
214
+ one worker runs a narrow objective, then a fresh verifier audits the result.
214
215
 
215
216
  ```bash
216
217
  loops templates list
@@ -230,6 +231,12 @@ loops templates render event-worker-verifier \
230
231
  --var eventSource=knowledge \
231
232
  --var eventJson='{"id":"<event-id>"}' \
232
233
  --var projectPath=/path/to/repo
234
+ loops templates render bounded-agent-worker-verifier \
235
+ --var objective="Check docs drift and queue tasks for gaps" \
236
+ --var projectPath=/path/to/repo \
237
+ --var provider=codewith \
238
+ --var authProfilePool=account004,account005 \
239
+ --var sandbox=danger-full-access
233
240
  ```
234
241
 
235
242
  For event-driven task automation, `loops events handle todos-task` reads a
@@ -293,6 +300,28 @@ loops run-now <id-or-name>
293
300
 
294
301
  Use `--json` for machine-readable output. Prompt bodies and run stdout/stderr are redacted by default in status output. `loops run-now` exits non-zero when the recorded run fails or times out.
295
302
 
303
+ ## Health And Hygiene
304
+
305
+ ```bash
306
+ loops health --json
307
+ loops expectations <loop-id-or-name> --json
308
+ loops health route-tasks --project ~/.hasna/loops --task-list loop-error-self-heal --max-actions 5
309
+ loops hygiene names --json
310
+ loops hygiene duplicates --json
311
+ loops hygiene scripts --json
312
+ ```
313
+
314
+ `health` and `expectations` classify latest-run failures with stable
315
+ fingerprints and bounded evidence. `health route-tasks` is the explicit
316
+ mutating path: it upserts deduped Todos tasks for failed expectations and marks
317
+ them with `no_tmux_dispatch=true` metadata. Use `--dry-run --json` before
318
+ turning it into a production loop.
319
+
320
+ `hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
321
+ renames only with `--apply`. `hygiene duplicates` groups loops with the same
322
+ normalized name, cwd, and schedule. `hygiene scripts` inventories loops whose
323
+ command still references `~/.hasna/loops/scripts`.
324
+
296
325
  Archive loops when retiring old automation but preserving history:
297
326
 
298
327
  ```bash
package/dist/cli/index.js CHANGED
@@ -1052,6 +1052,30 @@ class Store {
1052
1052
  throw new Error(`loop not found after update: ${id}`);
1053
1053
  return after;
1054
1054
  }
1055
+ renameLoop(id, name, opts = {}) {
1056
+ const current = this.getLoop(id);
1057
+ if (!current)
1058
+ throw new Error(`loop not found: ${id}`);
1059
+ const trimmed = name.trim();
1060
+ if (!trimmed)
1061
+ throw new Error("loop name must not be empty");
1062
+ const updated = (opts.now ?? new Date).toISOString();
1063
+ this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
1064
+ WHERE id=$id
1065
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1066
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1067
+ ))`).run({
1068
+ $id: id,
1069
+ $name: trimmed,
1070
+ $updated: updated,
1071
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1072
+ $now: updated
1073
+ });
1074
+ const after = this.getLoop(id);
1075
+ if (!after)
1076
+ throw new Error(`loop not found after rename: ${id}`);
1077
+ return after;
1078
+ }
1055
1079
  archiveLoop(idOrName) {
1056
1080
  const loop = this.requireLoop(idOrName);
1057
1081
  if (loop.archivedAt)
@@ -2173,6 +2197,7 @@ class Store {
2173
2197
  // src/cli/index.ts
2174
2198
  import { createHash as createHash2 } from "crypto";
2175
2199
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2200
+ import { spawnSync as spawnSync5 } from "child_process";
2176
2201
  import { Command } from "commander";
2177
2202
 
2178
2203
  // src/lib/format.ts
@@ -4615,6 +4640,7 @@ function runDoctor(store) {
4615
4640
  // src/lib/health.ts
4616
4641
  import { createHash } from "crypto";
4617
4642
  var EVIDENCE_CHARS = 2000;
4643
+ var FINGERPRINT_EVIDENCE_CHARS = 120;
4618
4644
  var CLASSIFICATIONS = [
4619
4645
  "rate_limit",
4620
4646
  "auth",
@@ -4643,6 +4669,15 @@ function stableFingerprint(parts) {
4643
4669
  return createHash("sha256").update(parts.join(`
4644
4670
  `)).digest("hex").slice(0, 16);
4645
4671
  }
4672
+ function stableFailureFingerprint(run, classification) {
4673
+ return stableFingerprint([
4674
+ run.loopId,
4675
+ classification,
4676
+ String(run.status),
4677
+ String(run.exitCode ?? ""),
4678
+ (run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
4679
+ ]);
4680
+ }
4646
4681
  function healthRun(run) {
4647
4682
  return {
4648
4683
  ...run,
@@ -4676,14 +4711,7 @@ function classifyRunFailure(run) {
4676
4711
  classification = "sigsegv";
4677
4712
  return {
4678
4713
  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
- ]),
4714
+ fingerprint: stableFailureFingerprint(run, classification),
4687
4715
  evidence: {
4688
4716
  error: bounded(run.error),
4689
4717
  stdout: bounded(run.stdout),
@@ -4799,7 +4827,7 @@ function expectationForLoop(store, loop) {
4799
4827
  };
4800
4828
  }
4801
4829
  function buildHealthReport(store, opts = {}) {
4802
- const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4830
+ const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 }).filter((loop) => opts.includeInactive || loop.status === "active" || loop.status === "paused");
4803
4831
  const expectations = loops.map((loop) => expectationForLoop(store, loop));
4804
4832
  const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4805
4833
  for (const expectation of expectations) {
@@ -4821,10 +4849,247 @@ function buildHealthReport(store, opts = {}) {
4821
4849
  expectations
4822
4850
  };
4823
4851
  }
4852
+
4853
+ // src/lib/hygiene.ts
4854
+ import { basename } from "path";
4855
+ var PROVIDER_TOKENS = new Set([
4856
+ "codewith",
4857
+ "claude",
4858
+ "command",
4859
+ "tmux",
4860
+ "codex",
4861
+ "cursor",
4862
+ "opencode",
4863
+ "aicopilot",
4864
+ "agent"
4865
+ ]);
4866
+ var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
4867
+ function slugify(value) {
4868
+ return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
4869
+ }
4870
+ function repoSlugFromCwd(cwd) {
4871
+ if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
4872
+ return "";
4873
+ if (cwd.includes("/.hasna/loops/"))
4874
+ return "";
4875
+ return slugify(basename(cwd));
4876
+ }
4877
+ function scopeForLoop(loop) {
4878
+ const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
4879
+ const repoSlug = repoSlugFromCwd(cwd);
4880
+ if (repoSlug)
4881
+ return { scope: "repo", prefix: `repo-${repoSlug}`, scopeSlug: repoSlug };
4882
+ return { scope: "machine", prefix: "machine", scopeSlug: "machine" };
4883
+ }
4884
+ function taskSlug(loop, scope) {
4885
+ const oldName = loop.name;
4886
+ let nameForParsing = oldName;
4887
+ if (!oldName.includes(":")) {
4888
+ const slug = slugify(oldName);
4889
+ if (scope.scope === "machine" && slug.startsWith("machine-"))
4890
+ nameForParsing = slug.slice("machine-".length);
4891
+ else if (scope.scope === "repo" && slug.startsWith(`repo-${scope.scopeSlug}-`)) {
4892
+ nameForParsing = slug.slice(`repo-${scope.scopeSlug}-`.length);
4893
+ } else
4894
+ nameForParsing = slug;
4895
+ }
4896
+ const parts = [];
4897
+ for (const rawPart of oldName.includes(":") ? oldName.split(":") : [nameForParsing]) {
4898
+ const part = slugify(rawPart);
4899
+ if (!part)
4900
+ continue;
4901
+ if (PROVIDER_TOKENS.has(part) || /^account\d+$/.test(part))
4902
+ continue;
4903
+ if (scope.scope === "repo" && REPO_GENERIC_TOKENS.has(part))
4904
+ continue;
4905
+ let normalized = part;
4906
+ if (scope.scope === "repo" && normalized === scope.scopeSlug)
4907
+ continue;
4908
+ if (scope.scope === "repo" && normalized.startsWith(`${scope.scopeSlug}-`)) {
4909
+ normalized = normalized.slice(scope.scopeSlug.length + 1);
4910
+ }
4911
+ if (normalized)
4912
+ parts.push(normalized);
4913
+ }
4914
+ const deduped = [];
4915
+ for (const token of parts.join("-").split("-").filter(Boolean)) {
4916
+ if (deduped[deduped.length - 1] !== token)
4917
+ deduped.push(token);
4918
+ }
4919
+ return deduped.join("-") || "loop";
4920
+ }
4921
+ function canonicalName(loop) {
4922
+ const scope = scopeForLoop(loop);
4923
+ let name = `${scope.prefix}-${taskSlug(loop, scope)}`.replace(/-+/g, "-").replace(/^-|-$/g, "");
4924
+ if (name.length > 120)
4925
+ name = `${name.slice(0, 111).replace(/-+$/g, "")}-${loop.id.slice(0, 8)}`;
4926
+ return {
4927
+ id: loop.id,
4928
+ status: loop.status,
4929
+ scope: scope.scope,
4930
+ scopeSlug: scope.scopeSlug,
4931
+ newName: name
4932
+ };
4933
+ }
4934
+ function ensureUnique(changes, existingNames = []) {
4935
+ const oldNames = new Set(changes.map((change) => change.oldName));
4936
+ const used = new Set([...existingNames].filter((name) => !oldNames.has(name)));
4937
+ for (const change of changes) {
4938
+ let candidate = change.newName;
4939
+ if (!used.has(candidate)) {
4940
+ used.add(candidate);
4941
+ change.newName = candidate;
4942
+ change.changed = change.oldName !== candidate;
4943
+ continue;
4944
+ }
4945
+ const base = candidate.slice(0, 111).replace(/-+$/g, "");
4946
+ candidate = `${base}-${change.id.slice(0, 8)}`;
4947
+ let suffix = 2;
4948
+ while (used.has(candidate)) {
4949
+ const extra = `-${change.id.slice(0, 6)}-${suffix++}`;
4950
+ candidate = `${base.slice(0, 120 - extra.length)}${extra}`;
4951
+ }
4952
+ used.add(candidate);
4953
+ change.newName = candidate;
4954
+ change.changed = change.oldName !== candidate;
4955
+ }
4956
+ }
4957
+ function managedLoops(store, opts) {
4958
+ const loops = store.listLoops({ includeArchived: Boolean(opts.includeInactive), limit: opts.limit ?? 1000 });
4959
+ if (opts.includeInactive)
4960
+ return loops;
4961
+ if (opts.includeStopped)
4962
+ return loops.filter((loop) => loop.status !== "expired");
4963
+ return loops.filter((loop) => loop.status === "active" || loop.status === "paused");
4964
+ }
4965
+ function buildNameHygieneReport(store, opts = {}) {
4966
+ const allLoops = store.listLoops({ includeArchived: true, limit: 1e4 });
4967
+ const changes = managedLoops(store, opts).map((loop) => {
4968
+ const canonical = canonicalName(loop);
4969
+ return {
4970
+ ...canonical,
4971
+ oldName: loop.name,
4972
+ changed: loop.name !== canonical.newName
4973
+ };
4974
+ });
4975
+ ensureUnique(changes, allLoops.map((loop) => loop.name));
4976
+ const changed = changes.filter((change) => change.changed);
4977
+ const conflicts = changes.filter((change) => allLoops.some((loop) => loop.name === change.newName && loop.id !== change.id));
4978
+ if (opts.apply) {
4979
+ for (const change of changed)
4980
+ store.renameLoop(change.id, change.newName);
4981
+ }
4982
+ return {
4983
+ ok: changed.length === 0,
4984
+ generatedAt: new Date().toISOString(),
4985
+ applied: Boolean(opts.apply),
4986
+ checked: changes.length,
4987
+ changed: changed.length,
4988
+ changes,
4989
+ conflicts
4990
+ };
4991
+ }
4992
+ function baseName(name) {
4993
+ return name.replace(/-(bounded|compact|native)?-?low(?:-\d+m)?$/g, "").replace(/-\d+[mhd]$/g, "").replace(/-(bounded|compact)$/g, "");
4994
+ }
4995
+ function scheduleKey(schedule) {
4996
+ if (schedule.type === "cron")
4997
+ return `cron:${schedule.expression}`;
4998
+ if (schedule.type === "interval")
4999
+ return `interval:${schedule.everyMs}`;
5000
+ if (schedule.type === "once")
5001
+ return `once:${schedule.at}`;
5002
+ return `dynamic:${schedule.minIntervalMs ?? ""}`;
5003
+ }
5004
+ function targetCwd(loop) {
5005
+ return loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd ?? "" : "";
5006
+ }
5007
+ function buildDuplicateOverlapReport(store, opts = {}) {
5008
+ const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5009
+ const groups = new Map;
5010
+ for (const loop of loops) {
5011
+ const base = baseName(loop.name);
5012
+ const cwd = targetCwd(loop) || undefined;
5013
+ const schedule = scheduleKey(loop.schedule);
5014
+ const key = `${base}|${cwd ?? ""}|${schedule}`;
5015
+ const existing = groups.get(key) ?? { baseName: base, cwd, schedule, loops: [] };
5016
+ existing.loops.push(loop);
5017
+ groups.set(key, existing);
5018
+ }
5019
+ const duplicateGroups = [...groups.entries()].filter(([, group]) => group.loops.length > 1).map(([key, group]) => ({
5020
+ key,
5021
+ baseName: group.baseName,
5022
+ cwd: group.cwd,
5023
+ schedule: group.schedule,
5024
+ loops: group.loops.map((loop) => ({
5025
+ id: loop.id,
5026
+ name: loop.name,
5027
+ status: loop.status,
5028
+ nextRunAt: loop.nextRunAt
5029
+ }))
5030
+ }));
5031
+ return {
5032
+ ok: duplicateGroups.length === 0,
5033
+ generatedAt: new Date().toISOString(),
5034
+ checked: loops.length,
5035
+ groups: duplicateGroups
5036
+ };
5037
+ }
5038
+ function commandText(loop) {
5039
+ if (loop.target.type !== "command")
5040
+ return "";
5041
+ return [loop.target.command, ...loop.target.args ?? []].join(" ");
5042
+ }
5043
+ function scriptNeedles(scriptsDir) {
5044
+ const home = process.env.HOME ?? "/home/hasna";
5045
+ const normalized = scriptsDir.replace(/\/+$/g, "");
5046
+ const values = [
5047
+ normalized,
5048
+ `${normalized}/`,
5049
+ "~/.hasna/loops/scripts",
5050
+ "~/.hasna/loops/scripts/",
5051
+ "$HOME/.hasna/loops/scripts",
5052
+ "$HOME/.hasna/loops/scripts/",
5053
+ "${HOME}/.hasna/loops/scripts",
5054
+ "${HOME}/.hasna/loops/scripts/",
5055
+ `${home}/.hasna/loops/scripts`,
5056
+ `${home}/.hasna/loops/scripts/`,
5057
+ "/.hasna/loops/scripts/"
5058
+ ];
5059
+ return [...new Set(values)];
5060
+ }
5061
+ function buildScriptInventoryReport(store, opts = {}) {
5062
+ const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
5063
+ const needles = scriptNeedles(scriptsDir);
5064
+ const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5065
+ const scriptBacked = loops.map((loop) => {
5066
+ const text = commandText(loop);
5067
+ if (!text)
5068
+ return;
5069
+ const matches = needles.filter((needle) => text.includes(needle));
5070
+ if (!matches.length)
5071
+ return;
5072
+ return {
5073
+ id: loop.id,
5074
+ name: loop.name,
5075
+ status: loop.status,
5076
+ cwd: targetCwd(loop) || undefined,
5077
+ command: text.length > 500 ? `${text.slice(0, 500)}...` : text,
5078
+ scriptMatches: [...new Set(matches)]
5079
+ };
5080
+ }).filter((value) => Boolean(value));
5081
+ return {
5082
+ ok: scriptBacked.length === 0,
5083
+ generatedAt: new Date().toISOString(),
5084
+ checked: loops.length,
5085
+ scriptBacked: scriptBacked.length,
5086
+ loops: scriptBacked
5087
+ };
5088
+ }
4824
5089
  // package.json
4825
5090
  var package_default = {
4826
5091
  name: "@hasna/loops",
4827
- version: "0.3.16",
5092
+ version: "0.3.18",
4828
5093
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4829
5094
  type: "module",
4830
5095
  main: "dist/index.js",
@@ -4915,6 +5180,7 @@ function packageVersion() {
4915
5180
  // src/lib/templates.ts
4916
5181
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
4917
5182
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5183
+ var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
4918
5184
  var TEMPLATE_SUMMARIES = [
4919
5185
  {
4920
5186
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -4959,6 +5225,28 @@ var TEMPLATE_SUMMARIES = [
4959
5225
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4960
5226
  { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
4961
5227
  ]
5228
+ },
5229
+ {
5230
+ id: BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID,
5231
+ name: "Bounded Agent Worker + Verifier",
5232
+ description: "Create a bounded recurring-agent workflow: one agent performs a narrow objective, then a fresh verifier audits the result with separate account/profile selection.",
5233
+ kind: "workflow",
5234
+ variables: [
5235
+ { name: "objective", required: true, description: "Narrow goal-mode objective for the worker." },
5236
+ { name: "prompt", description: "Optional extra worker prompt details." },
5237
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5238
+ { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
5239
+ { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
5240
+ { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
5241
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
5242
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
5243
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
5244
+ { name: "model", description: "Provider model." },
5245
+ { name: "variant", description: "Provider reasoning/model effort variant." },
5246
+ { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5247
+ { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5248
+ { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
5249
+ ]
4962
5250
  }
4963
5251
  ];
4964
5252
  function compactJson(value) {
@@ -5145,6 +5433,54 @@ function renderEventWorkerVerifierWorkflow(input) {
5145
5433
  ]
5146
5434
  };
5147
5435
  }
5436
+ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5437
+ if (!input.objective?.trim())
5438
+ throw new Error("objective is required");
5439
+ if (!input.projectPath?.trim())
5440
+ throw new Error("projectPath is required");
5441
+ const seed = `${input.projectPath}:${input.objective}`;
5442
+ const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
5443
+ const workerPrompt = [
5444
+ `/goal ${input.objective}`,
5445
+ "",
5446
+ "You are the worker step for a bounded OpenLoops agent workflow.",
5447
+ "Investigate first. Keep scope narrow, use local project/task systems as the source of truth when relevant, preserve unrelated changes, run focused validation, and record concise evidence.",
5448
+ "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.",
5449
+ input.prompt ? "" : undefined,
5450
+ input.prompt
5451
+ ].filter(Boolean).join(`
5452
+ `);
5453
+ const verifierPrompt = [
5454
+ `/goal Adversarially verify: ${input.objective}`,
5455
+ "",
5456
+ "You are the verifier step for a bounded OpenLoops agent workflow.",
5457
+ "Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
5458
+ "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."
5459
+ ].join(`
5460
+ `);
5461
+ return {
5462
+ name: input.name ?? `bounded-agent-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
5463
+ description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
5464
+ version: 1,
5465
+ steps: [
5466
+ {
5467
+ id: "worker",
5468
+ name: "Worker",
5469
+ description: "Execute the bounded objective and record evidence.",
5470
+ target: agentTarget(input, workerPrompt, "worker", seed),
5471
+ timeoutMs
5472
+ },
5473
+ {
5474
+ id: "verifier",
5475
+ name: "Verifier",
5476
+ description: "Adversarially verify the bounded objective result.",
5477
+ dependsOn: ["worker"],
5478
+ target: agentTarget(input, verifierPrompt, "verifier", seed),
5479
+ timeoutMs: Math.min(timeoutMs, 30 * 60000)
5480
+ }
5481
+ ]
5482
+ };
5483
+ }
5148
5484
  function renderLoopTemplate(id, values) {
5149
5485
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
5150
5486
  return renderTodosTaskWorkerVerifierWorkflow({
@@ -5191,6 +5527,27 @@ function renderLoopTemplate(id, values) {
5191
5527
  sandbox: values.sandbox
5192
5528
  });
5193
5529
  }
5530
+ if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
5531
+ return renderBoundedAgentWorkerVerifierWorkflow({
5532
+ name: values.name,
5533
+ objective: values.objective ?? "",
5534
+ prompt: values.prompt,
5535
+ projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
5536
+ provider: values.provider,
5537
+ authProfile: values.authProfile,
5538
+ authProfilePool: listVar(values.authProfilePool),
5539
+ workerAuthProfile: values.workerAuthProfile,
5540
+ verifierAuthProfile: values.verifierAuthProfile,
5541
+ account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
5542
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
5543
+ model: values.model,
5544
+ variant: values.variant,
5545
+ agent: values.agent,
5546
+ permissionMode: values.permissionMode,
5547
+ sandbox: values.sandbox,
5548
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
5549
+ });
5550
+ }
5194
5551
  throw new Error(`unknown template: ${id}`);
5195
5552
  }
5196
5553
  function listVar(value) {
@@ -5355,6 +5712,36 @@ function collectValues(value, previous = []) {
5355
5712
  previous.push(value);
5356
5713
  return previous;
5357
5714
  }
5715
+ function defaultLoopsProject() {
5716
+ return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME ?? "/home/hasna"}/.hasna/loops`;
5717
+ }
5718
+ function runLocalCommand(command, args, opts = {}) {
5719
+ const result = spawnSync5(command, args, {
5720
+ input: opts.input,
5721
+ encoding: "utf8",
5722
+ timeout: opts.timeoutMs ?? 30000,
5723
+ maxBuffer: 8 * 1024 * 1024,
5724
+ env: process.env
5725
+ });
5726
+ return {
5727
+ ok: result.status === 0,
5728
+ status: result.status,
5729
+ stdout: result.stdout || "",
5730
+ stderr: result.stderr || "",
5731
+ error: result.error ? String(result.error.message || result.error) : ""
5732
+ };
5733
+ }
5734
+ function ensureTodosTaskList(project, slug, name, description) {
5735
+ runLocalCommand("todos", ["--project", project, "task-lists", "--add", name, "--slug", slug, "-d", description]);
5736
+ const list = runLocalCommand("todos", ["--project", project, "--json", "task-lists"]);
5737
+ if (!list.ok)
5738
+ throw new Error(list.stderr || list.error || "failed to list todos task lists");
5739
+ const values = JSON.parse(list.stdout || "[]");
5740
+ const found = values.find((entry) => entry.slug === slug);
5741
+ if (!found)
5742
+ throw new Error(`todos task list not found after ensure: ${slug}`);
5743
+ return found.id;
5744
+ }
5358
5745
  function eventData(event) {
5359
5746
  const data = event.data;
5360
5747
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -6089,7 +6476,7 @@ program.command("expectations [idOrName]").description("evaluate deterministic l
6089
6476
  store.close();
6090
6477
  }
6091
6478
  });
6092
- program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
6479
+ var health = program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
6093
6480
  const store = new Store;
6094
6481
  try {
6095
6482
  const report = buildHealthReport(store);
@@ -6107,6 +6494,134 @@ program.command("health").description("summarize loop health and latest-run expe
6107
6494
  store.close();
6108
6495
  }
6109
6496
  });
6497
+ health.command("route-tasks").description("upsert deduped todos tasks for failed loop health expectations").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "loop-error-self-heal").option("--limit <n>", "maximum loops to inspect", "200").option("--max-actions <n>", "maximum todos tasks to upsert", "5").option("--include-inactive", "also route stopped or expired loops").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
6498
+ const store = new Store;
6499
+ try {
6500
+ const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
6501
+ const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask).slice(0, Number(opts.maxActions));
6502
+ const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "Loop Error Self Heal", "Deduped OpenLoops health expectation failures routed by loops health route-tasks.");
6503
+ const actions = failures.map((expectation) => {
6504
+ const task = expectation.recommendedTask;
6505
+ const metadata = {
6506
+ source: "openloops.health.route-tasks",
6507
+ loop_id: expectation.loop.id,
6508
+ loop_name: expectation.loop.name,
6509
+ run_id: expectation.latestRun?.id,
6510
+ classification: expectation.failure?.classification,
6511
+ fingerprint: task.dedupeKey,
6512
+ no_tmux_dispatch: true
6513
+ };
6514
+ if (opts.dryRun) {
6515
+ return { action: "would-upsert", title: task.title, fingerprint: task.dedupeKey, priority: task.priority, metadata };
6516
+ }
6517
+ const result = runLocalCommand("todos", [
6518
+ "--project",
6519
+ opts.project,
6520
+ "--json",
6521
+ "task",
6522
+ "upsert",
6523
+ "--fingerprint",
6524
+ task.dedupeKey,
6525
+ "--title",
6526
+ task.title,
6527
+ "-d",
6528
+ task.description,
6529
+ "--priority",
6530
+ task.priority,
6531
+ "--status",
6532
+ "pending",
6533
+ "--list",
6534
+ listId,
6535
+ "--tags",
6536
+ task.tags.join(","),
6537
+ "--metadata-json",
6538
+ JSON.stringify(metadata)
6539
+ ]);
6540
+ if (!result.ok) {
6541
+ return { action: "upsert-failed", fingerprint: task.dedupeKey, error: result.stderr || result.error || result.stdout };
6542
+ }
6543
+ return { action: "upserted", fingerprint: task.dedupeKey, task: JSON.parse(result.stdout || "{}") };
6544
+ });
6545
+ const routed = { ok: actions.every((action) => action.action !== "upsert-failed"), inspected: report.summary.loops, failures: failures.length, actions };
6546
+ if (isJson() || opts.json)
6547
+ console.log(JSON.stringify(routed, null, 2));
6548
+ else {
6549
+ console.log(`health_route_tasks inspected=${routed.inspected} failures=${routed.failures} actions=${actions.length}`);
6550
+ for (const action of actions)
6551
+ console.log(`${action.action} ${action.fingerprint}`);
6552
+ }
6553
+ if (!routed.ok)
6554
+ process.exitCode = 1;
6555
+ } finally {
6556
+ store.close();
6557
+ }
6558
+ });
6559
+ var hygiene = program.command("hygiene").description("deterministic OpenLoops hygiene checks and safe repairs");
6560
+ hygiene.command("names").description("check or apply canonical machine-/repo-prefixed loop names").option("--apply", "rename loops in-place").option("--include-stopped", "include stopped loops").option("--include-inactive", "include stopped, expired, and archived loops").option("--limit <n>", "maximum loops to inspect", "1000").option("--json", "print JSON").action((opts) => {
6561
+ const store = new Store;
6562
+ try {
6563
+ const report = buildNameHygieneReport(store, {
6564
+ apply: Boolean(opts.apply),
6565
+ includeStopped: Boolean(opts.includeStopped),
6566
+ includeInactive: Boolean(opts.includeInactive),
6567
+ limit: Number(opts.limit)
6568
+ });
6569
+ if (isJson() || opts.json)
6570
+ console.log(JSON.stringify(report, null, 2));
6571
+ else {
6572
+ console.log(`hygiene_names checked=${report.checked} changed=${report.changed} applied=${report.applied}`);
6573
+ for (const change of report.changes.filter((entry) => entry.changed)) {
6574
+ console.log(`${report.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
6575
+ }
6576
+ }
6577
+ if (!report.ok && !report.applied)
6578
+ process.exitCode = 1;
6579
+ } finally {
6580
+ store.close();
6581
+ }
6582
+ });
6583
+ hygiene.command("duplicates").description("detect duplicate/overlapping loops with the same canonical name, cwd, and schedule").option("--include-inactive", "include stopped, expired, and archived loops").option("--limit <n>", "maximum loops to inspect", "1000").option("--json", "print JSON").action((opts) => {
6584
+ const store = new Store;
6585
+ try {
6586
+ const report = buildDuplicateOverlapReport(store, {
6587
+ includeInactive: Boolean(opts.includeInactive),
6588
+ limit: Number(opts.limit)
6589
+ });
6590
+ if (isJson() || opts.json)
6591
+ console.log(JSON.stringify(report, null, 2));
6592
+ else {
6593
+ console.log(`hygiene_duplicates checked=${report.checked} groups=${report.groups.length}`);
6594
+ for (const group of report.groups) {
6595
+ console.log(`${group.key} ${group.loops.map((loop) => `${loop.id}:${loop.status}:${loop.name}`).join(",")}`);
6596
+ }
6597
+ }
6598
+ if (!report.ok)
6599
+ process.exitCode = 1;
6600
+ } finally {
6601
+ store.close();
6602
+ }
6603
+ });
6604
+ hygiene.command("scripts").description("inventory loops still backed by local ~/.hasna/loops/scripts commands").option("--scripts-dir <path>", "script directory to detect").option("--include-inactive", "include stopped, expired, and archived loops").option("--limit <n>", "maximum loops to inspect", "1000").option("--json", "print JSON").action((opts) => {
6605
+ const store = new Store;
6606
+ try {
6607
+ const report = buildScriptInventoryReport(store, {
6608
+ scriptsDir: opts.scriptsDir,
6609
+ includeInactive: Boolean(opts.includeInactive),
6610
+ limit: Number(opts.limit)
6611
+ });
6612
+ if (isJson() || opts.json)
6613
+ console.log(JSON.stringify(report, null, 2));
6614
+ else {
6615
+ console.log(`hygiene_scripts checked=${report.checked} script_backed=${report.scriptBacked}`);
6616
+ for (const loop of report.loops)
6617
+ console.log(`${loop.id} ${loop.status} ${loop.name} ${loop.command}`);
6618
+ }
6619
+ if (!report.ok)
6620
+ process.exitCode = 1;
6621
+ } finally {
6622
+ store.close();
6623
+ }
6624
+ });
6110
6625
  program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
6111
6626
  program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
6112
6627
  program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));