@hasna/loops 0.3.15 → 0.3.17

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`);
@@ -1033,6 +1052,30 @@ class Store {
1033
1052
  throw new Error(`loop not found after update: ${id}`);
1034
1053
  return after;
1035
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
+ }
1036
1079
  archiveLoop(idOrName) {
1037
1080
  const loop = this.requireLoop(idOrName);
1038
1081
  if (loop.archivedAt)
@@ -2152,8 +2195,9 @@ class Store {
2152
2195
  }
2153
2196
 
2154
2197
  // src/cli/index.ts
2155
- import { createHash } from "crypto";
2198
+ import { createHash as createHash2 } from "crypto";
2156
2199
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2200
+ import { spawnSync as spawnSync5 } from "child_process";
2157
2201
  import { Command } from "commander";
2158
2202
 
2159
2203
  // src/lib/format.ts
@@ -2571,6 +2615,16 @@ function metadataEnv(metadata) {
2571
2615
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2572
2616
  return env;
2573
2617
  }
2618
+ function allowlistEnv(allowlist) {
2619
+ const env = {};
2620
+ if (allowlist?.tools?.length)
2621
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2622
+ if (allowlist?.commands?.length)
2623
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2624
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2625
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2626
+ return env;
2627
+ }
2574
2628
  function providerCommand(provider) {
2575
2629
  switch (provider) {
2576
2630
  case "claude":
@@ -2778,7 +2832,8 @@ function commandSpec(target) {
2778
2832
  account: agentTarget.account,
2779
2833
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2780
2834
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2781
- stdin: agentTarget.prompt
2835
+ stdin: agentTarget.prompt,
2836
+ allowlist: agentTarget.allowlist
2782
2837
  };
2783
2838
  }
2784
2839
  function executionEnv(spec, metadata, opts) {
@@ -2790,6 +2845,7 @@ function executionEnv(spec, metadata, opts) {
2790
2845
  Object.assign(env, accountEnv);
2791
2846
  }
2792
2847
  Object.assign(env, spec.env ?? {});
2848
+ Object.assign(env, allowlistEnv(spec.allowlist));
2793
2849
  env.PATH = normalizeExecutionPath(env);
2794
2850
  Object.assign(env, metadataEnv(metadata));
2795
2851
  return env;
@@ -2828,6 +2884,9 @@ function remoteBootstrapLines(spec, metadata) {
2828
2884
  continue;
2829
2885
  lines.push(`export ${key}=${shellQuote(value)}`);
2830
2886
  }
2887
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2888
+ lines.push(`export ${key}=${shellQuote(value)}`);
2889
+ }
2831
2890
  return lines;
2832
2891
  }
2833
2892
  function remoteScript(spec, metadata) {
@@ -4577,10 +4636,434 @@ function runDoctor(store) {
4577
4636
  checks
4578
4637
  };
4579
4638
  }
4639
+
4640
+ // src/lib/health.ts
4641
+ import { createHash } from "crypto";
4642
+ var EVIDENCE_CHARS = 2000;
4643
+ var CLASSIFICATIONS = [
4644
+ "rate_limit",
4645
+ "auth",
4646
+ "model_not_found",
4647
+ "context_length",
4648
+ "schema_response_format",
4649
+ "node_init",
4650
+ "timeout",
4651
+ "sigsegv",
4652
+ "skipped_previous_active",
4653
+ "unknown"
4654
+ ];
4655
+ function bounded(value, limit = EVIDENCE_CHARS) {
4656
+ if (!value)
4657
+ return;
4658
+ if (value.length <= limit)
4659
+ return value;
4660
+ return `${value.slice(0, limit)}
4661
+ [truncated ${value.length - limit} chars]`;
4662
+ }
4663
+ function searchableText(run) {
4664
+ return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
4665
+ `).toLowerCase();
4666
+ }
4667
+ function stableFingerprint(parts) {
4668
+ return createHash("sha256").update(parts.join(`
4669
+ `)).digest("hex").slice(0, 16);
4670
+ }
4671
+ function healthRun(run) {
4672
+ return {
4673
+ ...run,
4674
+ error: bounded(run.error),
4675
+ stdout: bounded(run.stdout),
4676
+ stderr: bounded(run.stderr)
4677
+ };
4678
+ }
4679
+ function classifyRunFailure(run) {
4680
+ if (run.status === "succeeded" || run.status === "running")
4681
+ return;
4682
+ const text = searchableText(run);
4683
+ let classification = "unknown";
4684
+ if (run.status === "timed_out")
4685
+ classification = "timeout";
4686
+ else if (run.status === "skipped" && /previous run still active/.test(text))
4687
+ classification = "skipped_previous_active";
4688
+ else if (/rate limit|too many requests|429\b|quota exceeded/.test(text))
4689
+ classification = "rate_limit";
4690
+ else if (/unauthorized|authentication|auth\b|api key|invalid token|permission denied|401\b|403\b/.test(text))
4691
+ classification = "auth";
4692
+ else if (/model .*not found|model_not_found|unknown model|invalid model|404.*model/.test(text))
4693
+ classification = "model_not_found";
4694
+ else if (/context length|context_length|context window|maximum context|token limit|too many tokens/.test(text))
4695
+ classification = "context_length";
4696
+ else if (/response_format|json schema|schema validation|invalid schema|structured output/.test(text))
4697
+ classification = "schema_response_format";
4698
+ 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))
4699
+ classification = "node_init";
4700
+ else if (/sigsegv|segmentation fault|signal 11/.test(text))
4701
+ classification = "sigsegv";
4702
+ return {
4703
+ classification,
4704
+ fingerprint: stableFingerprint([
4705
+ run.loopId,
4706
+ run.loopName,
4707
+ run.status,
4708
+ classification,
4709
+ String(run.exitCode ?? ""),
4710
+ (run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
4711
+ ]),
4712
+ evidence: {
4713
+ error: bounded(run.error),
4714
+ stdout: bounded(run.stdout),
4715
+ stderr: bounded(run.stderr),
4716
+ exitCode: run.exitCode
4717
+ }
4718
+ };
4719
+ }
4720
+ function targetRoute(loop) {
4721
+ if (loop.target.type === "agent") {
4722
+ return {
4723
+ source: "openloops",
4724
+ kind: "loop_expectation",
4725
+ loopId: loop.id,
4726
+ loopName: loop.name,
4727
+ cwd: loop.target.cwd,
4728
+ provider: loop.target.provider
4729
+ };
4730
+ }
4731
+ if (loop.target.type === "command") {
4732
+ return {
4733
+ source: "openloops",
4734
+ kind: "loop_expectation",
4735
+ loopId: loop.id,
4736
+ loopName: loop.name,
4737
+ cwd: loop.target.cwd
4738
+ };
4739
+ }
4740
+ return {
4741
+ source: "openloops",
4742
+ kind: "loop_expectation",
4743
+ loopId: loop.id,
4744
+ loopName: loop.name
4745
+ };
4746
+ }
4747
+ function recommendedTask(loop, run, failure, route) {
4748
+ const title = `BUG: open-loops loop failure - ${loop.name}`;
4749
+ const description = [
4750
+ `OpenLoops expectation failed for loop ${loop.name} (${loop.id}).`,
4751
+ `Run: ${run.id}`,
4752
+ `Status: ${run.status}`,
4753
+ `Classification: ${failure.classification}`,
4754
+ `Fingerprint: ${failure.fingerprint}`,
4755
+ route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4756
+ route.provider ? `Provider: ${route.provider}` : undefined,
4757
+ failure.evidence.error ? `Error:
4758
+ ${failure.evidence.error}` : undefined,
4759
+ failure.evidence.stderr ? `Stderr:
4760
+ ${failure.evidence.stderr}` : undefined
4761
+ ].filter(Boolean).join(`
4762
+
4763
+ `);
4764
+ const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
4765
+ const tags = ["bug", "openloops", "loop-health", failure.classification];
4766
+ const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
4767
+ return {
4768
+ title,
4769
+ description,
4770
+ priority,
4771
+ tags,
4772
+ dedupeKey,
4773
+ search: { query: dedupeKey },
4774
+ compatibilityFallback: {
4775
+ search: ["todos", "search", dedupeKey, "--json"],
4776
+ add: ["todos", "add", title, "--description", description, "--tag", tags.join(","), "--priority", priority],
4777
+ comment: ["todos", "comment", "<task-id>", description]
4778
+ },
4779
+ futureNativeUpsert: {
4780
+ command: "todos upsert",
4781
+ fields: {
4782
+ title,
4783
+ description,
4784
+ priority,
4785
+ tags,
4786
+ dedupeKey,
4787
+ routeSource: route.source,
4788
+ routeKind: route.kind,
4789
+ routeLoopId: route.loopId,
4790
+ routeLoopName: route.loopName
4791
+ }
4792
+ }
4793
+ };
4794
+ }
4795
+ function expectationForLoop(store, loop) {
4796
+ const latestRun = store.listRuns({ loopId: loop.id, limit: 1 })[0];
4797
+ const route = targetRoute(loop);
4798
+ if (!latestRun) {
4799
+ return {
4800
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4801
+ ok: true,
4802
+ check: { id: "latest-run-succeeded", status: "warn", message: "loop has no recorded runs yet" },
4803
+ route
4804
+ };
4805
+ }
4806
+ if (latestRun.status === "succeeded") {
4807
+ return {
4808
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4809
+ ok: true,
4810
+ check: { id: "latest-run-succeeded", status: "pass", message: "latest run succeeded" },
4811
+ latestRun: healthRun(latestRun),
4812
+ route
4813
+ };
4814
+ }
4815
+ const failure = classifyRunFailure(latestRun);
4816
+ return {
4817
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4818
+ ok: false,
4819
+ check: { id: "latest-run-succeeded", status: "fail", message: `latest run is ${latestRun.status}` },
4820
+ latestRun: healthRun(latestRun),
4821
+ failure,
4822
+ route,
4823
+ recommendedTask: failure ? recommendedTask(loop, latestRun, failure, route) : undefined
4824
+ };
4825
+ }
4826
+ function buildHealthReport(store, opts = {}) {
4827
+ const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4828
+ const expectations = loops.map((loop) => expectationForLoop(store, loop));
4829
+ const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4830
+ for (const expectation of expectations) {
4831
+ if (expectation.failure)
4832
+ classifications[expectation.failure.classification] += 1;
4833
+ }
4834
+ const unhealthy = expectations.filter((expectation) => !expectation.ok).length;
4835
+ const warnings = expectations.filter((expectation) => expectation.check.status === "warn").length;
4836
+ return {
4837
+ ok: unhealthy === 0,
4838
+ generatedAt: new Date().toISOString(),
4839
+ summary: {
4840
+ loops: expectations.length,
4841
+ healthy: expectations.length - unhealthy,
4842
+ unhealthy,
4843
+ warnings
4844
+ },
4845
+ classifications,
4846
+ expectations
4847
+ };
4848
+ }
4849
+
4850
+ // src/lib/hygiene.ts
4851
+ import { basename } from "path";
4852
+ var PROVIDER_TOKENS = new Set([
4853
+ "codewith",
4854
+ "claude",
4855
+ "command",
4856
+ "tmux",
4857
+ "codex",
4858
+ "cursor",
4859
+ "opencode",
4860
+ "aicopilot",
4861
+ "agent"
4862
+ ]);
4863
+ var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
4864
+ function slugify(value) {
4865
+ return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
4866
+ }
4867
+ function repoSlugFromCwd(cwd) {
4868
+ if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
4869
+ return "";
4870
+ if (cwd.includes("/.hasna/loops/"))
4871
+ return "";
4872
+ return slugify(basename(cwd));
4873
+ }
4874
+ function scopeForLoop(loop) {
4875
+ const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
4876
+ const repoSlug = repoSlugFromCwd(cwd);
4877
+ if (repoSlug)
4878
+ return { scope: "repo", prefix: `repo-${repoSlug}`, scopeSlug: repoSlug };
4879
+ return { scope: "machine", prefix: "machine", scopeSlug: "machine" };
4880
+ }
4881
+ function taskSlug(loop, scope) {
4882
+ const oldName = loop.name;
4883
+ let nameForParsing = oldName;
4884
+ if (!oldName.includes(":")) {
4885
+ const slug = slugify(oldName);
4886
+ if (scope.scope === "machine" && slug.startsWith("machine-"))
4887
+ nameForParsing = slug.slice("machine-".length);
4888
+ else if (scope.scope === "repo" && slug.startsWith(`repo-${scope.scopeSlug}-`)) {
4889
+ nameForParsing = slug.slice(`repo-${scope.scopeSlug}-`.length);
4890
+ } else
4891
+ nameForParsing = slug;
4892
+ }
4893
+ const parts = [];
4894
+ for (const rawPart of oldName.includes(":") ? oldName.split(":") : [nameForParsing]) {
4895
+ const part = slugify(rawPart);
4896
+ if (!part)
4897
+ continue;
4898
+ if (PROVIDER_TOKENS.has(part) || /^account\d+$/.test(part))
4899
+ continue;
4900
+ if (scope.scope === "repo" && REPO_GENERIC_TOKENS.has(part))
4901
+ continue;
4902
+ let normalized = part;
4903
+ if (scope.scope === "repo" && normalized === scope.scopeSlug)
4904
+ continue;
4905
+ if (scope.scope === "repo" && normalized.startsWith(`${scope.scopeSlug}-`)) {
4906
+ normalized = normalized.slice(scope.scopeSlug.length + 1);
4907
+ }
4908
+ if (normalized)
4909
+ parts.push(normalized);
4910
+ }
4911
+ const deduped = [];
4912
+ for (const token of parts.join("-").split("-").filter(Boolean)) {
4913
+ if (deduped[deduped.length - 1] !== token)
4914
+ deduped.push(token);
4915
+ }
4916
+ return deduped.join("-") || "loop";
4917
+ }
4918
+ function canonicalName(loop) {
4919
+ const scope = scopeForLoop(loop);
4920
+ let name = `${scope.prefix}-${taskSlug(loop, scope)}`.replace(/-+/g, "-").replace(/^-|-$/g, "");
4921
+ if (name.length > 120)
4922
+ name = `${name.slice(0, 111).replace(/-+$/g, "")}-${loop.id.slice(0, 8)}`;
4923
+ return {
4924
+ id: loop.id,
4925
+ status: loop.status,
4926
+ scope: scope.scope,
4927
+ scopeSlug: scope.scopeSlug,
4928
+ newName: name
4929
+ };
4930
+ }
4931
+ function ensureUnique(changes) {
4932
+ const used = new Set;
4933
+ for (const change of changes) {
4934
+ let candidate = change.newName;
4935
+ if (!used.has(candidate)) {
4936
+ used.add(candidate);
4937
+ change.newName = candidate;
4938
+ change.changed = change.oldName !== candidate;
4939
+ continue;
4940
+ }
4941
+ const base = candidate.slice(0, 111).replace(/-+$/g, "");
4942
+ candidate = `${base}-${change.id.slice(0, 8)}`;
4943
+ let suffix = 2;
4944
+ while (used.has(candidate)) {
4945
+ const extra = `-${change.id.slice(0, 6)}-${suffix++}`;
4946
+ candidate = `${base.slice(0, 120 - extra.length)}${extra}`;
4947
+ }
4948
+ used.add(candidate);
4949
+ change.newName = candidate;
4950
+ change.changed = change.oldName !== candidate;
4951
+ }
4952
+ }
4953
+ function managedLoops(store, opts) {
4954
+ const loops = store.listLoops({ includeArchived: Boolean(opts.includeInactive), limit: opts.limit ?? 1000 });
4955
+ if (opts.includeInactive)
4956
+ return loops;
4957
+ if (opts.includeStopped)
4958
+ return loops.filter((loop) => loop.status !== "expired");
4959
+ return loops.filter((loop) => loop.status === "active" || loop.status === "paused");
4960
+ }
4961
+ function buildNameHygieneReport(store, opts = {}) {
4962
+ const changes = managedLoops(store, opts).map((loop) => {
4963
+ const canonical = canonicalName(loop);
4964
+ return {
4965
+ ...canonical,
4966
+ oldName: loop.name,
4967
+ changed: loop.name !== canonical.newName
4968
+ };
4969
+ });
4970
+ ensureUnique(changes);
4971
+ const changed = changes.filter((change) => change.changed);
4972
+ if (opts.apply) {
4973
+ for (const change of changed)
4974
+ store.renameLoop(change.id, change.newName);
4975
+ }
4976
+ return {
4977
+ ok: changed.length === 0,
4978
+ generatedAt: new Date().toISOString(),
4979
+ applied: Boolean(opts.apply),
4980
+ checked: changes.length,
4981
+ changed: changed.length,
4982
+ changes
4983
+ };
4984
+ }
4985
+ function baseName(name) {
4986
+ return name.replace(/-(bounded|compact|native)?-?low(?:-\d+m)?$/g, "").replace(/-\d+[mhd]$/g, "").replace(/-(bounded|compact)$/g, "");
4987
+ }
4988
+ function scheduleKey(schedule) {
4989
+ if (schedule.type === "cron")
4990
+ return `cron:${schedule.expression}`;
4991
+ if (schedule.type === "interval")
4992
+ return `interval:${schedule.everyMs}`;
4993
+ if (schedule.type === "once")
4994
+ return `once:${schedule.at}`;
4995
+ return `dynamic:${schedule.minIntervalMs ?? ""}`;
4996
+ }
4997
+ function targetCwd(loop) {
4998
+ return loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd ?? "" : "";
4999
+ }
5000
+ function buildDuplicateOverlapReport(store, opts = {}) {
5001
+ const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5002
+ const groups = new Map;
5003
+ for (const loop of loops) {
5004
+ const base = baseName(loop.name);
5005
+ const cwd = targetCwd(loop) || undefined;
5006
+ const schedule = scheduleKey(loop.schedule);
5007
+ const key = `${base}|${cwd ?? ""}|${schedule}`;
5008
+ const existing = groups.get(key) ?? { baseName: base, cwd, schedule, loops: [] };
5009
+ existing.loops.push(loop);
5010
+ groups.set(key, existing);
5011
+ }
5012
+ const duplicateGroups = [...groups.entries()].filter(([, group]) => group.loops.length > 1).map(([key, group]) => ({
5013
+ key,
5014
+ baseName: group.baseName,
5015
+ cwd: group.cwd,
5016
+ schedule: group.schedule,
5017
+ loops: group.loops.map((loop) => ({
5018
+ id: loop.id,
5019
+ name: loop.name,
5020
+ status: loop.status,
5021
+ nextRunAt: loop.nextRunAt
5022
+ }))
5023
+ }));
5024
+ return {
5025
+ ok: duplicateGroups.length === 0,
5026
+ generatedAt: new Date().toISOString(),
5027
+ checked: loops.length,
5028
+ groups: duplicateGroups
5029
+ };
5030
+ }
5031
+ function commandText(loop) {
5032
+ if (loop.target.type !== "command")
5033
+ return "";
5034
+ return [loop.target.command, ...loop.target.args ?? []].join(" ");
5035
+ }
5036
+ function buildScriptInventoryReport(store, opts = {}) {
5037
+ const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
5038
+ const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5039
+ const scriptBacked = loops.map((loop) => {
5040
+ const text = commandText(loop);
5041
+ if (!text)
5042
+ return;
5043
+ const matches = [scriptsDir, "/.hasna/loops/scripts/"].filter((needle) => text.includes(needle));
5044
+ if (!matches.length)
5045
+ return;
5046
+ return {
5047
+ id: loop.id,
5048
+ name: loop.name,
5049
+ status: loop.status,
5050
+ cwd: targetCwd(loop) || undefined,
5051
+ command: text.length > 500 ? `${text.slice(0, 500)}...` : text,
5052
+ scriptMatches: [...new Set(matches)]
5053
+ };
5054
+ }).filter((value) => Boolean(value));
5055
+ return {
5056
+ ok: scriptBacked.length === 0,
5057
+ generatedAt: new Date().toISOString(),
5058
+ checked: loops.length,
5059
+ scriptBacked: scriptBacked.length,
5060
+ loops: scriptBacked
5061
+ };
5062
+ }
4580
5063
  // package.json
4581
5064
  var package_default = {
4582
5065
  name: "@hasna/loops",
4583
- version: "0.3.15",
5066
+ version: "0.3.17",
4584
5067
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4585
5068
  type: "module",
4586
5069
  main: "dist/index.js",
@@ -4671,6 +5154,7 @@ function packageVersion() {
4671
5154
  // src/lib/templates.ts
4672
5155
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
4673
5156
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5157
+ var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
4674
5158
  var TEMPLATE_SUMMARIES = [
4675
5159
  {
4676
5160
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -4715,6 +5199,28 @@ var TEMPLATE_SUMMARIES = [
4715
5199
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4716
5200
  { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
4717
5201
  ]
5202
+ },
5203
+ {
5204
+ id: BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID,
5205
+ name: "Bounded Agent Worker + Verifier",
5206
+ 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.",
5207
+ kind: "workflow",
5208
+ variables: [
5209
+ { name: "objective", required: true, description: "Narrow goal-mode objective for the worker." },
5210
+ { name: "prompt", description: "Optional extra worker prompt details." },
5211
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
5212
+ { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
5213
+ { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
5214
+ { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
5215
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
5216
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
5217
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
5218
+ { name: "model", description: "Provider model." },
5219
+ { name: "variant", description: "Provider reasoning/model effort variant." },
5220
+ { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5221
+ { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5222
+ { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
5223
+ ]
4718
5224
  }
4719
5225
  ];
4720
5226
  function compactJson(value) {
@@ -4901,6 +5407,54 @@ function renderEventWorkerVerifierWorkflow(input) {
4901
5407
  ]
4902
5408
  };
4903
5409
  }
5410
+ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5411
+ if (!input.objective?.trim())
5412
+ throw new Error("objective is required");
5413
+ if (!input.projectPath?.trim())
5414
+ throw new Error("projectPath is required");
5415
+ const seed = `${input.projectPath}:${input.objective}`;
5416
+ const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
5417
+ const workerPrompt = [
5418
+ `/goal ${input.objective}`,
5419
+ "",
5420
+ "You are the worker step for a bounded OpenLoops agent workflow.",
5421
+ "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.",
5422
+ "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.",
5423
+ input.prompt ? "" : undefined,
5424
+ input.prompt
5425
+ ].filter(Boolean).join(`
5426
+ `);
5427
+ const verifierPrompt = [
5428
+ `/goal Adversarially verify: ${input.objective}`,
5429
+ "",
5430
+ "You are the verifier step for a bounded OpenLoops agent workflow.",
5431
+ "Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
5432
+ "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."
5433
+ ].join(`
5434
+ `);
5435
+ return {
5436
+ name: input.name ?? `bounded-agent-${stableIndex(seed, 65535).toString(16)}-worker-verifier`,
5437
+ description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
5438
+ version: 1,
5439
+ steps: [
5440
+ {
5441
+ id: "worker",
5442
+ name: "Worker",
5443
+ description: "Execute the bounded objective and record evidence.",
5444
+ target: agentTarget(input, workerPrompt, "worker", seed),
5445
+ timeoutMs
5446
+ },
5447
+ {
5448
+ id: "verifier",
5449
+ name: "Verifier",
5450
+ description: "Adversarially verify the bounded objective result.",
5451
+ dependsOn: ["worker"],
5452
+ target: agentTarget(input, verifierPrompt, "verifier", seed),
5453
+ timeoutMs: Math.min(timeoutMs, 30 * 60000)
5454
+ }
5455
+ ]
5456
+ };
5457
+ }
4904
5458
  function renderLoopTemplate(id, values) {
4905
5459
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
4906
5460
  return renderTodosTaskWorkerVerifierWorkflow({
@@ -4947,6 +5501,27 @@ function renderLoopTemplate(id, values) {
4947
5501
  sandbox: values.sandbox
4948
5502
  });
4949
5503
  }
5504
+ if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
5505
+ return renderBoundedAgentWorkerVerifierWorkflow({
5506
+ name: values.name,
5507
+ objective: values.objective ?? "",
5508
+ prompt: values.prompt,
5509
+ projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
5510
+ provider: values.provider,
5511
+ authProfile: values.authProfile,
5512
+ authProfilePool: listVar(values.authProfilePool),
5513
+ workerAuthProfile: values.workerAuthProfile,
5514
+ verifierAuthProfile: values.verifierAuthProfile,
5515
+ account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
5516
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
5517
+ model: values.model,
5518
+ variant: values.variant,
5519
+ agent: values.agent,
5520
+ permissionMode: values.permissionMode,
5521
+ sandbox: values.sandbox,
5522
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
5523
+ });
5524
+ }
4950
5525
  throw new Error(`unknown template: ${id}`);
4951
5526
  }
4952
5527
  function listVar(value) {
@@ -5080,6 +5655,17 @@ function splitList(value) {
5080
5655
  const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
5081
5656
  return values?.length ? values : undefined;
5082
5657
  }
5658
+ function allowlistFromOpts(opts) {
5659
+ const tools = (opts.allowTool ?? []).flatMap((entry) => splitList(entry) ?? []);
5660
+ const commands = (opts.allowCommand ?? []).flatMap((entry) => splitList(entry) ?? []);
5661
+ if (!tools.length && !commands.length)
5662
+ return;
5663
+ return {
5664
+ tools: tools.length ? tools : undefined,
5665
+ commands: commands.length ? commands : undefined,
5666
+ enforcement: "metadata_only"
5667
+ };
5668
+ }
5083
5669
  function accountPoolFromOpts(opts) {
5084
5670
  return splitList(opts.accountPool)?.map((profile) => ({ profile, tool: opts.accountTool }));
5085
5671
  }
@@ -5100,6 +5686,36 @@ function collectValues(value, previous = []) {
5100
5686
  previous.push(value);
5101
5687
  return previous;
5102
5688
  }
5689
+ function defaultLoopsProject() {
5690
+ return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME ?? "/home/hasna"}/.hasna/loops`;
5691
+ }
5692
+ function runLocalCommand(command, args, opts = {}) {
5693
+ const result = spawnSync5(command, args, {
5694
+ input: opts.input,
5695
+ encoding: "utf8",
5696
+ timeout: opts.timeoutMs ?? 30000,
5697
+ maxBuffer: 8 * 1024 * 1024,
5698
+ env: process.env
5699
+ });
5700
+ return {
5701
+ ok: result.status === 0,
5702
+ status: result.status,
5703
+ stdout: result.stdout || "",
5704
+ stderr: result.stderr || "",
5705
+ error: result.error ? String(result.error.message || result.error) : ""
5706
+ };
5707
+ }
5708
+ function ensureTodosTaskList(project, slug, name, description) {
5709
+ runLocalCommand("todos", ["--project", project, "task-lists", "--add", name, "--slug", slug, "-d", description]);
5710
+ const list = runLocalCommand("todos", ["--project", project, "--json", "task-lists"]);
5711
+ if (!list.ok)
5712
+ throw new Error(list.stderr || list.error || "failed to list todos task lists");
5713
+ const values = JSON.parse(list.stdout || "[]");
5714
+ const found = values.find((entry) => entry.slug === slug);
5715
+ if (!found)
5716
+ throw new Error(`todos task list not found after ensure: ${slug}`);
5717
+ return found.id;
5718
+ }
5103
5719
  function eventData(event) {
5104
5720
  const data = event.data;
5105
5721
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -5119,7 +5735,7 @@ function slugSegment(value, fallback = "event") {
5119
5735
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
5120
5736
  }
5121
5737
  function stableSuffix(value) {
5122
- return createHash("sha256").update(value).digest("hex").slice(0, 12);
5738
+ return createHash2("sha256").update(value).digest("hex").slice(0, 12);
5123
5739
  }
5124
5740
  function taskEventField(data, keys) {
5125
5741
  for (const key of keys) {
@@ -5145,6 +5761,85 @@ function taskEventField(data, keys) {
5145
5761
  }
5146
5762
  return;
5147
5763
  }
5764
+ function objectField(value) {
5765
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
5766
+ }
5767
+ function nestedObject(input, key) {
5768
+ return objectField(input[key]);
5769
+ }
5770
+ function taskEventRecords(data, metadata) {
5771
+ const records = [data];
5772
+ const dataTask = nestedObject(data, "task");
5773
+ if (dataTask)
5774
+ records.push(dataTask);
5775
+ const dataPayload = nestedObject(data, "payload");
5776
+ if (dataPayload) {
5777
+ records.push(dataPayload);
5778
+ const payloadTask = nestedObject(dataPayload, "task");
5779
+ if (payloadTask)
5780
+ records.push(payloadTask);
5781
+ }
5782
+ const dataMetadata = nestedObject(data, "metadata");
5783
+ if (dataMetadata)
5784
+ records.push(dataMetadata);
5785
+ records.push(metadata);
5786
+ const metadataTask = nestedObject(metadata, "task");
5787
+ if (metadataTask)
5788
+ records.push(metadataTask);
5789
+ const metadataAutomation = nestedObject(metadata, "automation");
5790
+ if (metadataAutomation)
5791
+ records.push(metadataAutomation);
5792
+ return records;
5793
+ }
5794
+ function booleanLike(value) {
5795
+ return value === true || value === "true" || value === "1" || value === 1;
5796
+ }
5797
+ function hasTruthyField(records, keys) {
5798
+ return records.some((record) => keys.some((key) => booleanLike(record[key])));
5799
+ }
5800
+ function tagsFromValue(value) {
5801
+ if (Array.isArray(value))
5802
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
5803
+ if (typeof value === "string")
5804
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
5805
+ return [];
5806
+ }
5807
+ function taskEventTags(records) {
5808
+ const tags = new Set;
5809
+ for (const record of records) {
5810
+ for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags))
5811
+ tags.add(tag);
5812
+ }
5813
+ return [...tags];
5814
+ }
5815
+ function taskRouteEligibility(data, metadata) {
5816
+ const records = taskEventRecords(data, metadata);
5817
+ const tags = taskEventTags(records);
5818
+ const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed", "allowed"]);
5819
+ if (!hasRouteOptIn)
5820
+ return { eligible: false, reason: "missing explicit route opt-in", tags };
5821
+ const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
5822
+ if (status && ["blocked", "completed", "done", "cancelled", "canceled", "failed", "archived"].includes(status)) {
5823
+ return { eligible: false, reason: `task status is not routable: ${status}`, tags };
5824
+ }
5825
+ const disallowedTags = tags.filter((tag) => ["no-auto", "manual", "manual-required", "approval-required"].includes(tag));
5826
+ if (disallowedTags.length)
5827
+ return { eligible: false, reason: `task has disallowed tag: ${disallowedTags[0]}`, tags };
5828
+ if (hasTruthyField(records, [
5829
+ "no_auto",
5830
+ "noAuto",
5831
+ "manual",
5832
+ "manual_required",
5833
+ "manualRequired",
5834
+ "requires_approval",
5835
+ "requiresApproval",
5836
+ "approval_required",
5837
+ "approvalRequired"
5838
+ ])) {
5839
+ return { eligible: false, reason: "task metadata requires manual or approval-gated handling", tags };
5840
+ }
5841
+ return { eligible: true, tags };
5842
+ }
5148
5843
  async function readEventEnvelopeFromStdin() {
5149
5844
  const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
5150
5845
  const event = JSON.parse(raw);
@@ -5217,7 +5912,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5217
5912
  store.close();
5218
5913
  }
5219
5914
  });
5220
- 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) => {
5915
+ 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) => {
5221
5916
  const provider = opts.provider;
5222
5917
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
5223
5918
  throw new Error("unsupported provider");
@@ -5240,6 +5935,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5240
5935
  configIsolation: opts.configIsolation,
5241
5936
  permissionMode: permissionModeFromOpts(opts, provider),
5242
5937
  sandbox: sandboxFromOpts(opts, provider),
5938
+ allowlist: allowlistFromOpts(opts),
5243
5939
  account: accountFromOpts(opts)
5244
5940
  };
5245
5941
  const loop = store.createLoop(baseCreateInput(name, opts, target));
@@ -5305,6 +6001,11 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5305
6001
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
5306
6002
  if (!taskId)
5307
6003
  throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
6004
+ const eligibility = taskRouteEligibility(data, metadata);
6005
+ if (!eligibility.eligible) {
6006
+ print({ skipped: true, reason: eligibility.reason, event, taskId, eligibility }, `skipped task ${taskId}: ${eligibility.reason}`);
6007
+ return;
6008
+ }
5308
6009
  const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
5309
6010
  const taskDescription = taskEventField(data, ["description", "body"]);
5310
6011
  const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
@@ -5729,6 +6430,172 @@ program.command("runs [idOrName]").option("--limit <n>", "limit", "50").option("
5729
6430
  store.close();
5730
6431
  }
5731
6432
  });
6433
+ 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) => {
6434
+ const store = new Store;
6435
+ try {
6436
+ const loops = idOrName ? [store.requireLoop(idOrName)] : store.listLoops({ limit: Number(opts.limit) });
6437
+ const values = loops.map((loop) => expectationForLoop(store, loop));
6438
+ if (isJson() || opts.json)
6439
+ console.log(JSON.stringify(idOrName ? values[0] : values, null, 2));
6440
+ else {
6441
+ for (const value of values) {
6442
+ console.log(`${value.ok ? "ok" : "fail"} ${value.loop.name} ${value.check.message}`);
6443
+ if (value.failure)
6444
+ console.log(` classification=${value.failure.classification} fingerprint=${value.failure.fingerprint}`);
6445
+ }
6446
+ }
6447
+ if (values.some((value) => !value.ok))
6448
+ process.exitCode = 1;
6449
+ } finally {
6450
+ store.close();
6451
+ }
6452
+ });
6453
+ var health = program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
6454
+ const store = new Store;
6455
+ try {
6456
+ const report = buildHealthReport(store);
6457
+ if (isJson() || opts.json)
6458
+ console.log(JSON.stringify(report, null, 2));
6459
+ else {
6460
+ console.log(`loops=${report.summary.loops} healthy=${report.summary.healthy} unhealthy=${report.summary.unhealthy} warnings=${report.summary.warnings}`);
6461
+ for (const expectation of report.expectations.filter((entry) => !entry.ok)) {
6462
+ console.log(`fail ${expectation.loop.name} ${expectation.failure?.classification ?? "unknown"} ${expectation.failure?.fingerprint ?? "-"}`);
6463
+ }
6464
+ }
6465
+ if (!report.ok)
6466
+ process.exitCode = 1;
6467
+ } finally {
6468
+ store.close();
6469
+ }
6470
+ });
6471
+ 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("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
6472
+ const store = new Store;
6473
+ try {
6474
+ const report = buildHealthReport(store, { limit: Number(opts.limit) });
6475
+ const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask).slice(0, Number(opts.maxActions));
6476
+ 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.");
6477
+ const actions = failures.map((expectation) => {
6478
+ const task = expectation.recommendedTask;
6479
+ const metadata = {
6480
+ source: "openloops.health.route-tasks",
6481
+ loop_id: expectation.loop.id,
6482
+ loop_name: expectation.loop.name,
6483
+ run_id: expectation.latestRun?.id,
6484
+ classification: expectation.failure?.classification,
6485
+ fingerprint: task.dedupeKey,
6486
+ no_tmux_dispatch: true
6487
+ };
6488
+ if (opts.dryRun) {
6489
+ return { action: "would-upsert", title: task.title, fingerprint: task.dedupeKey, priority: task.priority, metadata };
6490
+ }
6491
+ const result = runLocalCommand("todos", [
6492
+ "--project",
6493
+ opts.project,
6494
+ "--json",
6495
+ "task",
6496
+ "upsert",
6497
+ "--fingerprint",
6498
+ task.dedupeKey,
6499
+ "--title",
6500
+ task.title,
6501
+ "-d",
6502
+ task.description,
6503
+ "--priority",
6504
+ task.priority,
6505
+ "--status",
6506
+ "pending",
6507
+ "--list",
6508
+ listId,
6509
+ "--tags",
6510
+ task.tags.join(","),
6511
+ "--metadata-json",
6512
+ JSON.stringify(metadata)
6513
+ ]);
6514
+ if (!result.ok) {
6515
+ return { action: "upsert-failed", fingerprint: task.dedupeKey, error: result.stderr || result.error || result.stdout };
6516
+ }
6517
+ return { action: "upserted", fingerprint: task.dedupeKey, task: JSON.parse(result.stdout || "{}") };
6518
+ });
6519
+ const routed = { ok: actions.every((action) => action.action !== "upsert-failed"), inspected: report.summary.loops, failures: failures.length, actions };
6520
+ if (isJson() || opts.json)
6521
+ console.log(JSON.stringify(routed, null, 2));
6522
+ else {
6523
+ console.log(`health_route_tasks inspected=${routed.inspected} failures=${routed.failures} actions=${actions.length}`);
6524
+ for (const action of actions)
6525
+ console.log(`${action.action} ${action.fingerprint}`);
6526
+ }
6527
+ if (!routed.ok)
6528
+ process.exitCode = 1;
6529
+ } finally {
6530
+ store.close();
6531
+ }
6532
+ });
6533
+ var hygiene = program.command("hygiene").description("deterministic OpenLoops hygiene checks and safe repairs");
6534
+ 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) => {
6535
+ const store = new Store;
6536
+ try {
6537
+ const report = buildNameHygieneReport(store, {
6538
+ apply: Boolean(opts.apply),
6539
+ includeStopped: Boolean(opts.includeStopped),
6540
+ includeInactive: Boolean(opts.includeInactive),
6541
+ limit: Number(opts.limit)
6542
+ });
6543
+ if (isJson() || opts.json)
6544
+ console.log(JSON.stringify(report, null, 2));
6545
+ else {
6546
+ console.log(`hygiene_names checked=${report.checked} changed=${report.changed} applied=${report.applied}`);
6547
+ for (const change of report.changes.filter((entry) => entry.changed)) {
6548
+ console.log(`${report.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
6549
+ }
6550
+ }
6551
+ if (!report.ok && !report.applied)
6552
+ process.exitCode = 1;
6553
+ } finally {
6554
+ store.close();
6555
+ }
6556
+ });
6557
+ 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) => {
6558
+ const store = new Store;
6559
+ try {
6560
+ const report = buildDuplicateOverlapReport(store, {
6561
+ includeInactive: Boolean(opts.includeInactive),
6562
+ limit: Number(opts.limit)
6563
+ });
6564
+ if (isJson() || opts.json)
6565
+ console.log(JSON.stringify(report, null, 2));
6566
+ else {
6567
+ console.log(`hygiene_duplicates checked=${report.checked} groups=${report.groups.length}`);
6568
+ for (const group of report.groups) {
6569
+ console.log(`${group.key} ${group.loops.map((loop) => `${loop.id}:${loop.status}:${loop.name}`).join(",")}`);
6570
+ }
6571
+ }
6572
+ if (!report.ok)
6573
+ process.exitCode = 1;
6574
+ } finally {
6575
+ store.close();
6576
+ }
6577
+ });
6578
+ 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) => {
6579
+ const store = new Store;
6580
+ try {
6581
+ const report = buildScriptInventoryReport(store, {
6582
+ scriptsDir: opts.scriptsDir,
6583
+ includeInactive: Boolean(opts.includeInactive),
6584
+ limit: Number(opts.limit)
6585
+ });
6586
+ if (isJson() || opts.json)
6587
+ console.log(JSON.stringify(report, null, 2));
6588
+ else {
6589
+ console.log(`hygiene_scripts checked=${report.checked} script_backed=${report.scriptBacked}`);
6590
+ for (const loop of report.loops)
6591
+ console.log(`${loop.id} ${loop.status} ${loop.name} ${loop.command}`);
6592
+ }
6593
+ if (!report.ok)
6594
+ process.exitCode = 1;
6595
+ } finally {
6596
+ store.close();
6597
+ }
6598
+ });
5732
6599
  program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
5733
6600
  program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
5734
6601
  program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));