@hasna/loops 0.3.16 → 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/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
@@ -4821,10 +4846,224 @@ function buildHealthReport(store, opts = {}) {
4821
4846
  expectations
4822
4847
  };
4823
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
+ }
4824
5063
  // package.json
4825
5064
  var package_default = {
4826
5065
  name: "@hasna/loops",
4827
- version: "0.3.16",
5066
+ version: "0.3.17",
4828
5067
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4829
5068
  type: "module",
4830
5069
  main: "dist/index.js",
@@ -4915,6 +5154,7 @@ function packageVersion() {
4915
5154
  // src/lib/templates.ts
4916
5155
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
4917
5156
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5157
+ var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
4918
5158
  var TEMPLATE_SUMMARIES = [
4919
5159
  {
4920
5160
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -4959,6 +5199,28 @@ var TEMPLATE_SUMMARIES = [
4959
5199
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4960
5200
  { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
4961
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
+ ]
4962
5224
  }
4963
5225
  ];
4964
5226
  function compactJson(value) {
@@ -5145,6 +5407,54 @@ function renderEventWorkerVerifierWorkflow(input) {
5145
5407
  ]
5146
5408
  };
5147
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
+ }
5148
5458
  function renderLoopTemplate(id, values) {
5149
5459
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
5150
5460
  return renderTodosTaskWorkerVerifierWorkflow({
@@ -5191,6 +5501,27 @@ function renderLoopTemplate(id, values) {
5191
5501
  sandbox: values.sandbox
5192
5502
  });
5193
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
+ }
5194
5525
  throw new Error(`unknown template: ${id}`);
5195
5526
  }
5196
5527
  function listVar(value) {
@@ -5355,6 +5686,36 @@ function collectValues(value, previous = []) {
5355
5686
  previous.push(value);
5356
5687
  return previous;
5357
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
+ }
5358
5719
  function eventData(event) {
5359
5720
  const data = event.data;
5360
5721
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -6089,7 +6450,7 @@ program.command("expectations [idOrName]").description("evaluate deterministic l
6089
6450
  store.close();
6090
6451
  }
6091
6452
  });
6092
- program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
6453
+ var health = program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
6093
6454
  const store = new Store;
6094
6455
  try {
6095
6456
  const report = buildHealthReport(store);
@@ -6107,6 +6468,134 @@ program.command("health").description("summarize loop health and latest-run expe
6107
6468
  store.close();
6108
6469
  }
6109
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
+ });
6110
6599
  program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
6111
6600
  program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
6112
6601
  program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
@@ -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)
@@ -4395,7 +4419,7 @@ function enableStartup(result) {
4395
4419
  // package.json
4396
4420
  var package_default = {
4397
4421
  name: "@hasna/loops",
4398
- version: "0.3.16",
4422
+ version: "0.3.17",
4399
4423
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4400
4424
  type: "module",
4401
4425
  main: "dist/index.js",
package/dist/index.d.ts CHANGED
@@ -8,9 +8,10 @@ export { listOpenMachines, refreshLoopMachine, resolveLoopMachine } from "./lib/
8
8
  export { tick } from "./lib/scheduler.js";
9
9
  export { executeWorkflow, executeLoopTarget, preflightWorkflow } from "./lib/workflow-runner.js";
10
10
  export { workflowExecutionOrder, workflowBodyFromJson } from "./lib/workflow-spec.js";
11
- export { EVENT_WORKER_VERIFIER_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, getLoopTemplate, listLoopTemplates, renderEventWorkerVerifierWorkflow, renderLoopTemplate, renderTodosTaskWorkerVerifierWorkflow, } from "./lib/templates.js";
11
+ export { BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID, EVENT_WORKER_VERIFIER_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, getLoopTemplate, listLoopTemplates, renderBoundedAgentWorkerVerifierWorkflow, renderEventWorkerVerifierWorkflow, renderLoopTemplate, renderTodosTaskWorkerVerifierWorkflow, } from "./lib/templates.js";
12
12
  export { runDoctor } from "./lib/doctor.js";
13
13
  export { buildHealthReport, classifyRunFailure, expectationForLoop } from "./lib/health.js";
14
+ export { buildDuplicateOverlapReport, buildNameHygieneReport, buildScriptInventoryReport } from "./lib/hygiene.js";
14
15
  export { runGoal } from "./lib/goal/runner.js";
15
16
  export { resolveGoalModel } from "./lib/goal/model-factory.js";
16
17
  export { isTerminal as isGoalTerminal, readyNodeKeys, rollupSummary } from "./lib/goal/status.js";
package/dist/index.js CHANGED
@@ -1050,6 +1050,30 @@ class Store {
1050
1050
  throw new Error(`loop not found after update: ${id}`);
1051
1051
  return after;
1052
1052
  }
1053
+ renameLoop(id, name, opts = {}) {
1054
+ const current = this.getLoop(id);
1055
+ if (!current)
1056
+ throw new Error(`loop not found: ${id}`);
1057
+ const trimmed = name.trim();
1058
+ if (!trimmed)
1059
+ throw new Error("loop name must not be empty");
1060
+ const updated = (opts.now ?? new Date).toISOString();
1061
+ this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
1062
+ WHERE id=$id
1063
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1064
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1065
+ ))`).run({
1066
+ $id: id,
1067
+ $name: trimmed,
1068
+ $updated: updated,
1069
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1070
+ $now: updated
1071
+ });
1072
+ const after = this.getLoop(id);
1073
+ if (!after)
1074
+ throw new Error(`loop not found after rename: ${id}`);
1075
+ return after;
1076
+ }
1053
1077
  archiveLoop(idOrName) {
1054
1078
  const loop = this.requireLoop(idOrName);
1055
1079
  if (loop.archivedAt)
@@ -4094,6 +4118,7 @@ function loops(opts = {}) {
4094
4118
  // src/lib/templates.ts
4095
4119
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
4096
4120
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4121
+ var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
4097
4122
  var TEMPLATE_SUMMARIES = [
4098
4123
  {
4099
4124
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -4138,6 +4163,28 @@ var TEMPLATE_SUMMARIES = [
4138
4163
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4139
4164
  { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
4140
4165
  ]
4166
+ },
4167
+ {
4168
+ id: BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID,
4169
+ name: "Bounded Agent Worker + Verifier",
4170
+ 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.",
4171
+ kind: "workflow",
4172
+ variables: [
4173
+ { name: "objective", required: true, description: "Narrow goal-mode objective for the worker." },
4174
+ { name: "prompt", description: "Optional extra worker prompt details." },
4175
+ { name: "projectPath", required: true, description: "Repository or project working directory." },
4176
+ { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
4177
+ { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
4178
+ { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
4179
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
4180
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
4181
+ { name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
4182
+ { name: "model", description: "Provider model." },
4183
+ { name: "variant", description: "Provider reasoning/model effort variant." },
4184
+ { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4185
+ { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
4186
+ { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
4187
+ ]
4141
4188
  }
4142
4189
  ];
4143
4190
  function compactJson(value) {
@@ -4324,6 +4371,54 @@ function renderEventWorkerVerifierWorkflow(input) {
4324
4371
  ]
4325
4372
  };
4326
4373
  }
4374
+ function renderBoundedAgentWorkerVerifierWorkflow(input) {
4375
+ if (!input.objective?.trim())
4376
+ throw new Error("objective is required");
4377
+ if (!input.projectPath?.trim())
4378
+ throw new Error("projectPath is required");
4379
+ const seed = `${input.projectPath}:${input.objective}`;
4380
+ const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
4381
+ const workerPrompt = [
4382
+ `/goal ${input.objective}`,
4383
+ "",
4384
+ "You are the worker step for a bounded OpenLoops agent workflow.",
4385
+ "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.",
4386
+ "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.",
4387
+ input.prompt ? "" : undefined,
4388
+ input.prompt
4389
+ ].filter(Boolean).join(`
4390
+ `);
4391
+ const verifierPrompt = [
4392
+ `/goal Adversarially verify: ${input.objective}`,
4393
+ "",
4394
+ "You are the verifier step for a bounded OpenLoops agent workflow.",
4395
+ "Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
4396
+ "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."
4397
+ ].join(`
4398
+ `);
4399
+ return {
4400
+ name: input.name ?? `bounded-agent-${stableIndex(seed, 65535).toString(16)}-worker-verifier`,
4401
+ description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
4402
+ version: 1,
4403
+ steps: [
4404
+ {
4405
+ id: "worker",
4406
+ name: "Worker",
4407
+ description: "Execute the bounded objective and record evidence.",
4408
+ target: agentTarget(input, workerPrompt, "worker", seed),
4409
+ timeoutMs
4410
+ },
4411
+ {
4412
+ id: "verifier",
4413
+ name: "Verifier",
4414
+ description: "Adversarially verify the bounded objective result.",
4415
+ dependsOn: ["worker"],
4416
+ target: agentTarget(input, verifierPrompt, "verifier", seed),
4417
+ timeoutMs: Math.min(timeoutMs, 30 * 60000)
4418
+ }
4419
+ ]
4420
+ };
4421
+ }
4327
4422
  function renderLoopTemplate(id, values) {
4328
4423
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
4329
4424
  return renderTodosTaskWorkerVerifierWorkflow({
@@ -4370,6 +4465,27 @@ function renderLoopTemplate(id, values) {
4370
4465
  sandbox: values.sandbox
4371
4466
  });
4372
4467
  }
4468
+ if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
4469
+ return renderBoundedAgentWorkerVerifierWorkflow({
4470
+ name: values.name,
4471
+ objective: values.objective ?? "",
4472
+ prompt: values.prompt,
4473
+ projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
4474
+ provider: values.provider,
4475
+ authProfile: values.authProfile,
4476
+ authProfilePool: listVar(values.authProfilePool),
4477
+ workerAuthProfile: values.workerAuthProfile,
4478
+ verifierAuthProfile: values.verifierAuthProfile,
4479
+ account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
4480
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
4481
+ model: values.model,
4482
+ variant: values.variant,
4483
+ agent: values.agent,
4484
+ permissionMode: values.permissionMode,
4485
+ sandbox: values.sandbox,
4486
+ timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
4487
+ });
4488
+ }
4373
4489
  throw new Error(`unknown template: ${id}`);
4374
4490
  }
4375
4491
  function listVar(value) {
@@ -4825,6 +4941,219 @@ function buildHealthReport(store, opts = {}) {
4825
4941
  expectations
4826
4942
  };
4827
4943
  }
4944
+ // src/lib/hygiene.ts
4945
+ import { basename } from "path";
4946
+ var PROVIDER_TOKENS = new Set([
4947
+ "codewith",
4948
+ "claude",
4949
+ "command",
4950
+ "tmux",
4951
+ "codex",
4952
+ "cursor",
4953
+ "opencode",
4954
+ "aicopilot",
4955
+ "agent"
4956
+ ]);
4957
+ var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
4958
+ function slugify(value) {
4959
+ return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
4960
+ }
4961
+ function repoSlugFromCwd(cwd) {
4962
+ if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
4963
+ return "";
4964
+ if (cwd.includes("/.hasna/loops/"))
4965
+ return "";
4966
+ return slugify(basename(cwd));
4967
+ }
4968
+ function scopeForLoop(loop) {
4969
+ const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
4970
+ const repoSlug = repoSlugFromCwd(cwd);
4971
+ if (repoSlug)
4972
+ return { scope: "repo", prefix: `repo-${repoSlug}`, scopeSlug: repoSlug };
4973
+ return { scope: "machine", prefix: "machine", scopeSlug: "machine" };
4974
+ }
4975
+ function taskSlug(loop, scope) {
4976
+ const oldName = loop.name;
4977
+ let nameForParsing = oldName;
4978
+ if (!oldName.includes(":")) {
4979
+ const slug = slugify(oldName);
4980
+ if (scope.scope === "machine" && slug.startsWith("machine-"))
4981
+ nameForParsing = slug.slice("machine-".length);
4982
+ else if (scope.scope === "repo" && slug.startsWith(`repo-${scope.scopeSlug}-`)) {
4983
+ nameForParsing = slug.slice(`repo-${scope.scopeSlug}-`.length);
4984
+ } else
4985
+ nameForParsing = slug;
4986
+ }
4987
+ const parts = [];
4988
+ for (const rawPart of oldName.includes(":") ? oldName.split(":") : [nameForParsing]) {
4989
+ const part = slugify(rawPart);
4990
+ if (!part)
4991
+ continue;
4992
+ if (PROVIDER_TOKENS.has(part) || /^account\d+$/.test(part))
4993
+ continue;
4994
+ if (scope.scope === "repo" && REPO_GENERIC_TOKENS.has(part))
4995
+ continue;
4996
+ let normalized = part;
4997
+ if (scope.scope === "repo" && normalized === scope.scopeSlug)
4998
+ continue;
4999
+ if (scope.scope === "repo" && normalized.startsWith(`${scope.scopeSlug}-`)) {
5000
+ normalized = normalized.slice(scope.scopeSlug.length + 1);
5001
+ }
5002
+ if (normalized)
5003
+ parts.push(normalized);
5004
+ }
5005
+ const deduped = [];
5006
+ for (const token of parts.join("-").split("-").filter(Boolean)) {
5007
+ if (deduped[deduped.length - 1] !== token)
5008
+ deduped.push(token);
5009
+ }
5010
+ return deduped.join("-") || "loop";
5011
+ }
5012
+ function canonicalName(loop) {
5013
+ const scope = scopeForLoop(loop);
5014
+ let name = `${scope.prefix}-${taskSlug(loop, scope)}`.replace(/-+/g, "-").replace(/^-|-$/g, "");
5015
+ if (name.length > 120)
5016
+ name = `${name.slice(0, 111).replace(/-+$/g, "")}-${loop.id.slice(0, 8)}`;
5017
+ return {
5018
+ id: loop.id,
5019
+ status: loop.status,
5020
+ scope: scope.scope,
5021
+ scopeSlug: scope.scopeSlug,
5022
+ newName: name
5023
+ };
5024
+ }
5025
+ function ensureUnique(changes) {
5026
+ const used = new Set;
5027
+ for (const change of changes) {
5028
+ let candidate = change.newName;
5029
+ if (!used.has(candidate)) {
5030
+ used.add(candidate);
5031
+ change.newName = candidate;
5032
+ change.changed = change.oldName !== candidate;
5033
+ continue;
5034
+ }
5035
+ const base = candidate.slice(0, 111).replace(/-+$/g, "");
5036
+ candidate = `${base}-${change.id.slice(0, 8)}`;
5037
+ let suffix = 2;
5038
+ while (used.has(candidate)) {
5039
+ const extra = `-${change.id.slice(0, 6)}-${suffix++}`;
5040
+ candidate = `${base.slice(0, 120 - extra.length)}${extra}`;
5041
+ }
5042
+ used.add(candidate);
5043
+ change.newName = candidate;
5044
+ change.changed = change.oldName !== candidate;
5045
+ }
5046
+ }
5047
+ function managedLoops(store, opts) {
5048
+ const loops2 = store.listLoops({ includeArchived: Boolean(opts.includeInactive), limit: opts.limit ?? 1000 });
5049
+ if (opts.includeInactive)
5050
+ return loops2;
5051
+ if (opts.includeStopped)
5052
+ return loops2.filter((loop) => loop.status !== "expired");
5053
+ return loops2.filter((loop) => loop.status === "active" || loop.status === "paused");
5054
+ }
5055
+ function buildNameHygieneReport(store, opts = {}) {
5056
+ const changes = managedLoops(store, opts).map((loop) => {
5057
+ const canonical = canonicalName(loop);
5058
+ return {
5059
+ ...canonical,
5060
+ oldName: loop.name,
5061
+ changed: loop.name !== canonical.newName
5062
+ };
5063
+ });
5064
+ ensureUnique(changes);
5065
+ const changed = changes.filter((change) => change.changed);
5066
+ if (opts.apply) {
5067
+ for (const change of changed)
5068
+ store.renameLoop(change.id, change.newName);
5069
+ }
5070
+ return {
5071
+ ok: changed.length === 0,
5072
+ generatedAt: new Date().toISOString(),
5073
+ applied: Boolean(opts.apply),
5074
+ checked: changes.length,
5075
+ changed: changed.length,
5076
+ changes
5077
+ };
5078
+ }
5079
+ function baseName(name) {
5080
+ return name.replace(/-(bounded|compact|native)?-?low(?:-\d+m)?$/g, "").replace(/-\d+[mhd]$/g, "").replace(/-(bounded|compact)$/g, "");
5081
+ }
5082
+ function scheduleKey(schedule) {
5083
+ if (schedule.type === "cron")
5084
+ return `cron:${schedule.expression}`;
5085
+ if (schedule.type === "interval")
5086
+ return `interval:${schedule.everyMs}`;
5087
+ if (schedule.type === "once")
5088
+ return `once:${schedule.at}`;
5089
+ return `dynamic:${schedule.minIntervalMs ?? ""}`;
5090
+ }
5091
+ function targetCwd(loop) {
5092
+ return loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd ?? "" : "";
5093
+ }
5094
+ function buildDuplicateOverlapReport(store, opts = {}) {
5095
+ const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5096
+ const groups = new Map;
5097
+ for (const loop of loops2) {
5098
+ const base = baseName(loop.name);
5099
+ const cwd = targetCwd(loop) || undefined;
5100
+ const schedule = scheduleKey(loop.schedule);
5101
+ const key = `${base}|${cwd ?? ""}|${schedule}`;
5102
+ const existing = groups.get(key) ?? { baseName: base, cwd, schedule, loops: [] };
5103
+ existing.loops.push(loop);
5104
+ groups.set(key, existing);
5105
+ }
5106
+ const duplicateGroups = [...groups.entries()].filter(([, group]) => group.loops.length > 1).map(([key, group]) => ({
5107
+ key,
5108
+ baseName: group.baseName,
5109
+ cwd: group.cwd,
5110
+ schedule: group.schedule,
5111
+ loops: group.loops.map((loop) => ({
5112
+ id: loop.id,
5113
+ name: loop.name,
5114
+ status: loop.status,
5115
+ nextRunAt: loop.nextRunAt
5116
+ }))
5117
+ }));
5118
+ return {
5119
+ ok: duplicateGroups.length === 0,
5120
+ generatedAt: new Date().toISOString(),
5121
+ checked: loops2.length,
5122
+ groups: duplicateGroups
5123
+ };
5124
+ }
5125
+ function commandText(loop) {
5126
+ if (loop.target.type !== "command")
5127
+ return "";
5128
+ return [loop.target.command, ...loop.target.args ?? []].join(" ");
5129
+ }
5130
+ function buildScriptInventoryReport(store, opts = {}) {
5131
+ const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
5132
+ const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5133
+ const scriptBacked = loops2.map((loop) => {
5134
+ const text = commandText(loop);
5135
+ if (!text)
5136
+ return;
5137
+ const matches = [scriptsDir, "/.hasna/loops/scripts/"].filter((needle) => text.includes(needle));
5138
+ if (!matches.length)
5139
+ return;
5140
+ return {
5141
+ id: loop.id,
5142
+ name: loop.name,
5143
+ status: loop.status,
5144
+ cwd: targetCwd(loop) || undefined,
5145
+ command: text.length > 500 ? `${text.slice(0, 500)}...` : text,
5146
+ scriptMatches: [...new Set(matches)]
5147
+ };
5148
+ }).filter((value) => Boolean(value));
5149
+ return {
5150
+ ok: scriptBacked.length === 0,
5151
+ generatedAt: new Date().toISOString(),
5152
+ checked: loops2.length,
5153
+ scriptBacked: scriptBacked.length,
5154
+ loops: scriptBacked
5155
+ };
5156
+ }
4828
5157
  export {
4829
5158
  workflowExecutionOrder,
4830
5159
  workflowBodyFromJson,
@@ -4837,6 +5166,7 @@ export {
4837
5166
  renderTodosTaskWorkerVerifierWorkflow,
4838
5167
  renderLoopTemplate,
4839
5168
  renderEventWorkerVerifierWorkflow,
5169
+ renderBoundedAgentWorkerVerifierWorkflow,
4840
5170
  refreshLoopMachine,
4841
5171
  readyNodeKeys,
4842
5172
  preflightWorkflow,
@@ -4857,9 +5187,13 @@ export {
4857
5187
  executeLoop,
4858
5188
  computeNextAfter,
4859
5189
  classifyRunFailure,
5190
+ buildScriptInventoryReport,
5191
+ buildNameHygieneReport,
4860
5192
  buildHealthReport,
5193
+ buildDuplicateOverlapReport,
4861
5194
  TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
4862
5195
  Store,
4863
5196
  LoopsClient,
4864
- EVENT_WORKER_VERIFIER_TEMPLATE_ID
5197
+ EVENT_WORKER_VERIFIER_TEMPLATE_ID,
5198
+ BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID
4865
5199
  };
@@ -0,0 +1,62 @@
1
+ import type { Loop } from "../types.js";
2
+ import type { Store } from "./store.js";
3
+ export interface NameHygieneChange {
4
+ id: string;
5
+ status: string;
6
+ scope: "machine" | "repo";
7
+ scopeSlug: string;
8
+ oldName: string;
9
+ newName: string;
10
+ changed: boolean;
11
+ }
12
+ export interface NameHygieneReport {
13
+ ok: boolean;
14
+ generatedAt: string;
15
+ applied: boolean;
16
+ checked: number;
17
+ changed: number;
18
+ changes: NameHygieneChange[];
19
+ }
20
+ export interface DuplicateOverlapGroup {
21
+ key: string;
22
+ baseName: string;
23
+ cwd?: string;
24
+ schedule: string;
25
+ loops: Array<Pick<Loop, "id" | "name" | "status" | "nextRunAt">>;
26
+ }
27
+ export interface DuplicateOverlapReport {
28
+ ok: boolean;
29
+ generatedAt: string;
30
+ checked: number;
31
+ groups: DuplicateOverlapGroup[];
32
+ }
33
+ export interface ScriptBackedLoop {
34
+ id: string;
35
+ name: string;
36
+ status: string;
37
+ cwd?: string;
38
+ command: string;
39
+ scriptMatches: string[];
40
+ }
41
+ export interface ScriptInventoryReport {
42
+ ok: boolean;
43
+ generatedAt: string;
44
+ checked: number;
45
+ scriptBacked: number;
46
+ loops: ScriptBackedLoop[];
47
+ }
48
+ export declare function buildNameHygieneReport(store: Store, opts?: {
49
+ apply?: boolean;
50
+ includeStopped?: boolean;
51
+ includeInactive?: boolean;
52
+ limit?: number;
53
+ }): NameHygieneReport;
54
+ export declare function buildDuplicateOverlapReport(store: Store, opts?: {
55
+ includeInactive?: boolean;
56
+ limit?: number;
57
+ }): DuplicateOverlapReport;
58
+ export declare function buildScriptInventoryReport(store: Store, opts?: {
59
+ scriptsDir?: string;
60
+ includeInactive?: boolean;
61
+ limit?: number;
62
+ }): ScriptInventoryReport;
@@ -79,6 +79,7 @@ export declare class Store {
79
79
  }): Loop[];
80
80
  dueLoops(now: Date): Loop[];
81
81
  updateLoop(id: string, patch: Partial<Pick<Loop, "status" | "nextRunAt" | "retryScheduledFor" | "expiresAt">>, opts?: DaemonLeaseFence): Loop;
82
+ renameLoop(id: string, name: string, opts?: DaemonLeaseFence): Loop;
82
83
  archiveLoop(idOrName: string): Loop;
83
84
  unarchiveLoop(idOrName: string): Loop;
84
85
  deleteLoop(idOrName: string): boolean;
package/dist/lib/store.js CHANGED
@@ -1050,6 +1050,30 @@ class Store {
1050
1050
  throw new Error(`loop not found after update: ${id}`);
1051
1051
  return after;
1052
1052
  }
1053
+ renameLoop(id, name, opts = {}) {
1054
+ const current = this.getLoop(id);
1055
+ if (!current)
1056
+ throw new Error(`loop not found: ${id}`);
1057
+ const trimmed = name.trim();
1058
+ if (!trimmed)
1059
+ throw new Error("loop name must not be empty");
1060
+ const updated = (opts.now ?? new Date).toISOString();
1061
+ this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
1062
+ WHERE id=$id
1063
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1064
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1065
+ ))`).run({
1066
+ $id: id,
1067
+ $name: trimmed,
1068
+ $updated: updated,
1069
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1070
+ $now: updated
1071
+ });
1072
+ const after = this.getLoop(id);
1073
+ if (!after)
1074
+ throw new Error(`loop not found after rename: ${id}`);
1075
+ return after;
1076
+ }
1053
1077
  archiveLoop(idOrName) {
1054
1078
  const loop = this.requireLoop(idOrName);
1055
1079
  if (loop.archivedAt)
@@ -1,6 +1,7 @@
1
1
  import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, CreateWorkflowInput, LoopTemplateSummary } from "../types.js";
2
2
  export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
3
3
  export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4
+ export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
4
5
  export interface TodosTaskWorkflowTemplateInput {
5
6
  taskId: string;
6
7
  taskTitle?: string;
@@ -46,8 +47,30 @@ export interface EventWorkflowTemplateInput {
46
47
  permissionMode?: AgentPermissionMode;
47
48
  sandbox?: AgentSandbox;
48
49
  }
50
+ export interface BoundedAgentWorkflowTemplateInput {
51
+ name?: string;
52
+ objective: string;
53
+ prompt?: string;
54
+ projectPath: string;
55
+ provider?: AgentProvider;
56
+ authProfile?: string;
57
+ authProfilePool?: string[];
58
+ workerAuthProfile?: string;
59
+ verifierAuthProfile?: string;
60
+ account?: AccountRef;
61
+ accountPool?: AccountRef[];
62
+ workerAccount?: AccountRef;
63
+ verifierAccount?: AccountRef;
64
+ model?: string;
65
+ variant?: string;
66
+ agent?: string;
67
+ permissionMode?: AgentPermissionMode;
68
+ sandbox?: AgentSandbox;
69
+ timeoutMs?: number;
70
+ }
49
71
  export declare function listLoopTemplates(): LoopTemplateSummary[];
50
72
  export declare function getLoopTemplate(id: string): LoopTemplateSummary | undefined;
51
73
  export declare function renderTodosTaskWorkerVerifierWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
52
74
  export declare function renderEventWorkerVerifierWorkflow(input: EventWorkflowTemplateInput): CreateWorkflowInput;
75
+ export declare function renderBoundedAgentWorkerVerifierWorkflow(input: BoundedAgentWorkflowTemplateInput): CreateWorkflowInput;
53
76
  export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>): CreateWorkflowInput;
package/dist/sdk/index.js CHANGED
@@ -1050,6 +1050,30 @@ class Store {
1050
1050
  throw new Error(`loop not found after update: ${id}`);
1051
1051
  return after;
1052
1052
  }
1053
+ renameLoop(id, name, opts = {}) {
1054
+ const current = this.getLoop(id);
1055
+ if (!current)
1056
+ throw new Error(`loop not found: ${id}`);
1057
+ const trimmed = name.trim();
1058
+ if (!trimmed)
1059
+ throw new Error("loop name must not be empty");
1060
+ const updated = (opts.now ?? new Date).toISOString();
1061
+ this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
1062
+ WHERE id=$id
1063
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1064
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1065
+ ))`).run({
1066
+ $id: id,
1067
+ $name: trimmed,
1068
+ $updated: updated,
1069
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1070
+ $now: updated
1071
+ });
1072
+ const after = this.getLoop(id);
1073
+ if (!after)
1074
+ throw new Error(`loop not found after rename: ${id}`);
1075
+ return after;
1076
+ }
1053
1077
  archiveLoop(idOrName) {
1054
1078
  const loop = this.requireLoop(idOrName);
1055
1079
  if (loop.archivedAt)
package/docs/USAGE.md CHANGED
@@ -184,7 +184,8 @@ Use `shell: true` only when you intentionally want shell parsing:
184
184
  Built-in templates turn common orchestration flows into reusable workflow JSON.
185
185
  `todos-task-worker-verifier` performs one todos task and then verifies it.
186
186
  `event-worker-verifier` handles any Hasna event envelope and then verifies the
187
- handling.
187
+ handling. `bounded-agent-worker-verifier` is for recurring bounded agent work:
188
+ one worker runs a narrow objective, then a fresh verifier audits the result.
188
189
 
189
190
  ```bash
190
191
  loops templates list
@@ -204,6 +205,12 @@ loops templates render event-worker-verifier \
204
205
  --var eventSource=knowledge \
205
206
  --var eventJson='{"id":"<event-id>"}' \
206
207
  --var projectPath=/path/to/repo
208
+ loops templates render bounded-agent-worker-verifier \
209
+ --var objective="Check docs drift and queue tasks for gaps" \
210
+ --var projectPath=/path/to/repo \
211
+ --var provider=codewith \
212
+ --var authProfilePool=account004,account005 \
213
+ --var sandbox=danger-full-access
207
214
  ```
208
215
 
209
216
  For event-driven task automation, `loops events handle todos-task` reads a
@@ -287,15 +294,42 @@ loops expectations <loop-id-or-name> --json
287
294
 
288
295
  The JSON contains the expectation result, bounded error/stdout/stderr evidence,
289
296
  a stable failure fingerprint, route metadata, and recommended task fields.
290
- OpenLoops does not mutate Todos from these commands. Until Todos has a native
291
- upsert command, consumers can use the included compatibility fallback:
292
- `todos search <dedupe-key>`, then `todos add ...` or `todos comment ...`.
293
- The planned native integration is represented in `futureNativeUpsert`.
297
+ OpenLoops does not mutate Todos from `health` or `expectations`. To turn failed
298
+ expectations into deduped tasks, use the explicit routing command:
299
+
300
+ ```bash
301
+ loops health route-tasks \
302
+ --project ~/.hasna/loops \
303
+ --task-list loop-error-self-heal \
304
+ --max-actions 5
305
+ ```
306
+
307
+ Use `--dry-run --json` first when testing a new automation path. Routed tasks
308
+ include the stable failure fingerprint, classification, loop id/name, and
309
+ `no_tmux_dispatch=true` metadata.
294
310
 
295
311
  Failure classifications are: `rate_limit`, `auth`, `model_not_found`,
296
312
  `context_length`, `schema_response_format`, `node_init`, `timeout`, `sigsegv`,
297
313
  `skipped_previous_active`, and `unknown`.
298
314
 
315
+ ## Hygiene
316
+
317
+ OpenLoops includes deterministic hygiene checks for replacing local maintenance
318
+ scripts with package commands:
319
+
320
+ ```bash
321
+ loops hygiene names --json
322
+ loops hygiene names --apply
323
+ loops hygiene duplicates --json
324
+ loops hygiene scripts --json
325
+ ```
326
+
327
+ `hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
328
+ only renames when `--apply` is present. `hygiene duplicates` groups loops with
329
+ the same normalized name, cwd, and schedule. `hygiene scripts` inventories loops
330
+ whose command still references `~/.hasna/loops/scripts`; use it as a migration
331
+ gate before deleting local scripts.
332
+
299
333
  Archive loops when retiring old automation but preserving history:
300
334
 
301
335
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",