@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/index.js CHANGED
@@ -326,6 +326,17 @@ function optionalPositiveInteger(value, label) {
326
326
  throw new Error(`${label} must be a positive integer`);
327
327
  return value;
328
328
  }
329
+ function optionalStringArray(value, label) {
330
+ if (value === undefined)
331
+ return;
332
+ if (!Array.isArray(value))
333
+ throw new Error(`${label} must be an array`);
334
+ const values = value.map((entry, index) => {
335
+ assertString(entry, `${label}[${index}]`);
336
+ return entry.trim();
337
+ }).filter(Boolean);
338
+ return values.length ? values : undefined;
339
+ }
329
340
  function normalizeGoalSpec(value, label = "goal") {
330
341
  if (value === undefined)
331
342
  return;
@@ -397,6 +408,14 @@ function validateTarget(value, label) {
397
408
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
398
409
  }
399
410
  }
411
+ if (value.allowlist !== undefined) {
412
+ assertObject(value.allowlist, `${label}.allowlist`);
413
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
414
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
415
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
416
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
417
+ }
418
+ }
400
419
  return value;
401
420
  }
402
421
  throw new Error(`${label}.type must be command or agent`);
@@ -1031,6 +1050,30 @@ class Store {
1031
1050
  throw new Error(`loop not found after update: ${id}`);
1032
1051
  return after;
1033
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
+ }
1034
1077
  archiveLoop(idOrName) {
1035
1078
  const loop = this.requireLoop(idOrName);
1036
1079
  if (loop.archivedAt)
@@ -2455,6 +2498,16 @@ function metadataEnv(metadata) {
2455
2498
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2456
2499
  return env;
2457
2500
  }
2501
+ function allowlistEnv(allowlist) {
2502
+ const env = {};
2503
+ if (allowlist?.tools?.length)
2504
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2505
+ if (allowlist?.commands?.length)
2506
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2507
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2508
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2509
+ return env;
2510
+ }
2458
2511
  function providerCommand(provider) {
2459
2512
  switch (provider) {
2460
2513
  case "claude":
@@ -2662,7 +2715,8 @@ function commandSpec(target) {
2662
2715
  account: agentTarget.account,
2663
2716
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2664
2717
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2665
- stdin: agentTarget.prompt
2718
+ stdin: agentTarget.prompt,
2719
+ allowlist: agentTarget.allowlist
2666
2720
  };
2667
2721
  }
2668
2722
  function executionEnv(spec, metadata, opts) {
@@ -2674,6 +2728,7 @@ function executionEnv(spec, metadata, opts) {
2674
2728
  Object.assign(env, accountEnv);
2675
2729
  }
2676
2730
  Object.assign(env, spec.env ?? {});
2731
+ Object.assign(env, allowlistEnv(spec.allowlist));
2677
2732
  env.PATH = normalizeExecutionPath(env);
2678
2733
  Object.assign(env, metadataEnv(metadata));
2679
2734
  return env;
@@ -2712,6 +2767,9 @@ function remoteBootstrapLines(spec, metadata) {
2712
2767
  continue;
2713
2768
  lines.push(`export ${key}=${shellQuote(value)}`);
2714
2769
  }
2770
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2771
+ lines.push(`export ${key}=${shellQuote(value)}`);
2772
+ }
2715
2773
  return lines;
2716
2774
  }
2717
2775
  function remoteScript(spec, metadata) {
@@ -4060,6 +4118,7 @@ function loops(opts = {}) {
4060
4118
  // src/lib/templates.ts
4061
4119
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
4062
4120
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4121
+ var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
4063
4122
  var TEMPLATE_SUMMARIES = [
4064
4123
  {
4065
4124
  id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
@@ -4104,6 +4163,28 @@ var TEMPLATE_SUMMARIES = [
4104
4163
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
4105
4164
  { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
4106
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
+ ]
4107
4188
  }
4108
4189
  ];
4109
4190
  function compactJson(value) {
@@ -4290,6 +4371,54 @@ function renderEventWorkerVerifierWorkflow(input) {
4290
4371
  ]
4291
4372
  };
4292
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
+ }
4293
4422
  function renderLoopTemplate(id, values) {
4294
4423
  if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
4295
4424
  return renderTodosTaskWorkerVerifierWorkflow({
@@ -4336,6 +4465,27 @@ function renderLoopTemplate(id, values) {
4336
4465
  sandbox: values.sandbox
4337
4466
  });
4338
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
+ }
4339
4489
  throw new Error(`unknown template: ${id}`);
4340
4490
  }
4341
4491
  function listVar(value) {
@@ -4582,6 +4732,428 @@ function runDoctor(store) {
4582
4732
  checks
4583
4733
  };
4584
4734
  }
4735
+ // src/lib/health.ts
4736
+ import { createHash } from "crypto";
4737
+ var EVIDENCE_CHARS = 2000;
4738
+ var CLASSIFICATIONS = [
4739
+ "rate_limit",
4740
+ "auth",
4741
+ "model_not_found",
4742
+ "context_length",
4743
+ "schema_response_format",
4744
+ "node_init",
4745
+ "timeout",
4746
+ "sigsegv",
4747
+ "skipped_previous_active",
4748
+ "unknown"
4749
+ ];
4750
+ function bounded(value, limit = EVIDENCE_CHARS) {
4751
+ if (!value)
4752
+ return;
4753
+ if (value.length <= limit)
4754
+ return value;
4755
+ return `${value.slice(0, limit)}
4756
+ [truncated ${value.length - limit} chars]`;
4757
+ }
4758
+ function searchableText(run) {
4759
+ return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
4760
+ `).toLowerCase();
4761
+ }
4762
+ function stableFingerprint(parts) {
4763
+ return createHash("sha256").update(parts.join(`
4764
+ `)).digest("hex").slice(0, 16);
4765
+ }
4766
+ function healthRun(run) {
4767
+ return {
4768
+ ...run,
4769
+ error: bounded(run.error),
4770
+ stdout: bounded(run.stdout),
4771
+ stderr: bounded(run.stderr)
4772
+ };
4773
+ }
4774
+ function classifyRunFailure(run) {
4775
+ if (run.status === "succeeded" || run.status === "running")
4776
+ return;
4777
+ const text = searchableText(run);
4778
+ let classification = "unknown";
4779
+ if (run.status === "timed_out")
4780
+ classification = "timeout";
4781
+ else if (run.status === "skipped" && /previous run still active/.test(text))
4782
+ classification = "skipped_previous_active";
4783
+ else if (/rate limit|too many requests|429\b|quota exceeded/.test(text))
4784
+ classification = "rate_limit";
4785
+ else if (/unauthorized|authentication|auth\b|api key|invalid token|permission denied|401\b|403\b/.test(text))
4786
+ classification = "auth";
4787
+ else if (/model .*not found|model_not_found|unknown model|invalid model|404.*model/.test(text))
4788
+ classification = "model_not_found";
4789
+ else if (/context length|context_length|context window|maximum context|token limit|too many tokens/.test(text))
4790
+ classification = "context_length";
4791
+ else if (/response_format|json schema|schema validation|invalid schema|structured output/.test(text))
4792
+ classification = "schema_response_format";
4793
+ 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))
4794
+ classification = "node_init";
4795
+ else if (/sigsegv|segmentation fault|signal 11/.test(text))
4796
+ classification = "sigsegv";
4797
+ return {
4798
+ classification,
4799
+ fingerprint: stableFingerprint([
4800
+ run.loopId,
4801
+ run.loopName,
4802
+ run.status,
4803
+ classification,
4804
+ String(run.exitCode ?? ""),
4805
+ (run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
4806
+ ]),
4807
+ evidence: {
4808
+ error: bounded(run.error),
4809
+ stdout: bounded(run.stdout),
4810
+ stderr: bounded(run.stderr),
4811
+ exitCode: run.exitCode
4812
+ }
4813
+ };
4814
+ }
4815
+ function targetRoute(loop) {
4816
+ if (loop.target.type === "agent") {
4817
+ return {
4818
+ source: "openloops",
4819
+ kind: "loop_expectation",
4820
+ loopId: loop.id,
4821
+ loopName: loop.name,
4822
+ cwd: loop.target.cwd,
4823
+ provider: loop.target.provider
4824
+ };
4825
+ }
4826
+ if (loop.target.type === "command") {
4827
+ return {
4828
+ source: "openloops",
4829
+ kind: "loop_expectation",
4830
+ loopId: loop.id,
4831
+ loopName: loop.name,
4832
+ cwd: loop.target.cwd
4833
+ };
4834
+ }
4835
+ return {
4836
+ source: "openloops",
4837
+ kind: "loop_expectation",
4838
+ loopId: loop.id,
4839
+ loopName: loop.name
4840
+ };
4841
+ }
4842
+ function recommendedTask(loop, run, failure, route) {
4843
+ const title = `BUG: open-loops loop failure - ${loop.name}`;
4844
+ const description = [
4845
+ `OpenLoops expectation failed for loop ${loop.name} (${loop.id}).`,
4846
+ `Run: ${run.id}`,
4847
+ `Status: ${run.status}`,
4848
+ `Classification: ${failure.classification}`,
4849
+ `Fingerprint: ${failure.fingerprint}`,
4850
+ route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4851
+ route.provider ? `Provider: ${route.provider}` : undefined,
4852
+ failure.evidence.error ? `Error:
4853
+ ${failure.evidence.error}` : undefined,
4854
+ failure.evidence.stderr ? `Stderr:
4855
+ ${failure.evidence.stderr}` : undefined
4856
+ ].filter(Boolean).join(`
4857
+
4858
+ `);
4859
+ const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
4860
+ const tags = ["bug", "openloops", "loop-health", failure.classification];
4861
+ const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
4862
+ return {
4863
+ title,
4864
+ description,
4865
+ priority,
4866
+ tags,
4867
+ dedupeKey,
4868
+ search: { query: dedupeKey },
4869
+ compatibilityFallback: {
4870
+ search: ["todos", "search", dedupeKey, "--json"],
4871
+ add: ["todos", "add", title, "--description", description, "--tag", tags.join(","), "--priority", priority],
4872
+ comment: ["todos", "comment", "<task-id>", description]
4873
+ },
4874
+ futureNativeUpsert: {
4875
+ command: "todos upsert",
4876
+ fields: {
4877
+ title,
4878
+ description,
4879
+ priority,
4880
+ tags,
4881
+ dedupeKey,
4882
+ routeSource: route.source,
4883
+ routeKind: route.kind,
4884
+ routeLoopId: route.loopId,
4885
+ routeLoopName: route.loopName
4886
+ }
4887
+ }
4888
+ };
4889
+ }
4890
+ function expectationForLoop(store, loop) {
4891
+ const latestRun = store.listRuns({ loopId: loop.id, limit: 1 })[0];
4892
+ const route = targetRoute(loop);
4893
+ if (!latestRun) {
4894
+ return {
4895
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4896
+ ok: true,
4897
+ check: { id: "latest-run-succeeded", status: "warn", message: "loop has no recorded runs yet" },
4898
+ route
4899
+ };
4900
+ }
4901
+ if (latestRun.status === "succeeded") {
4902
+ return {
4903
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4904
+ ok: true,
4905
+ check: { id: "latest-run-succeeded", status: "pass", message: "latest run succeeded" },
4906
+ latestRun: healthRun(latestRun),
4907
+ route
4908
+ };
4909
+ }
4910
+ const failure = classifyRunFailure(latestRun);
4911
+ return {
4912
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4913
+ ok: false,
4914
+ check: { id: "latest-run-succeeded", status: "fail", message: `latest run is ${latestRun.status}` },
4915
+ latestRun: healthRun(latestRun),
4916
+ failure,
4917
+ route,
4918
+ recommendedTask: failure ? recommendedTask(loop, latestRun, failure, route) : undefined
4919
+ };
4920
+ }
4921
+ function buildHealthReport(store, opts = {}) {
4922
+ const loops2 = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4923
+ const expectations = loops2.map((loop) => expectationForLoop(store, loop));
4924
+ const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4925
+ for (const expectation of expectations) {
4926
+ if (expectation.failure)
4927
+ classifications[expectation.failure.classification] += 1;
4928
+ }
4929
+ const unhealthy = expectations.filter((expectation) => !expectation.ok).length;
4930
+ const warnings = expectations.filter((expectation) => expectation.check.status === "warn").length;
4931
+ return {
4932
+ ok: unhealthy === 0,
4933
+ generatedAt: new Date().toISOString(),
4934
+ summary: {
4935
+ loops: expectations.length,
4936
+ healthy: expectations.length - unhealthy,
4937
+ unhealthy,
4938
+ warnings
4939
+ },
4940
+ classifications,
4941
+ expectations
4942
+ };
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
+ }
4585
5157
  export {
4586
5158
  workflowExecutionOrder,
4587
5159
  workflowBodyFromJson,
@@ -4594,6 +5166,7 @@ export {
4594
5166
  renderTodosTaskWorkerVerifierWorkflow,
4595
5167
  renderLoopTemplate,
4596
5168
  renderEventWorkerVerifierWorkflow,
5169
+ renderBoundedAgentWorkerVerifierWorkflow,
4597
5170
  refreshLoopMachine,
4598
5171
  readyNodeKeys,
4599
5172
  preflightWorkflow,
@@ -4607,13 +5180,20 @@ export {
4607
5180
  isTerminal as isGoalTerminal,
4608
5181
  initialNextRun,
4609
5182
  getLoopTemplate,
5183
+ expectationForLoop,
4610
5184
  executeWorkflow,
4611
5185
  executeTarget,
4612
5186
  executeLoopTarget,
4613
5187
  executeLoop,
4614
5188
  computeNextAfter,
5189
+ classifyRunFailure,
5190
+ buildScriptInventoryReport,
5191
+ buildNameHygieneReport,
5192
+ buildHealthReport,
5193
+ buildDuplicateOverlapReport,
4615
5194
  TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
4616
5195
  Store,
4617
5196
  LoopsClient,
4618
- EVENT_WORKER_VERIFIER_TEMPLATE_ID
5197
+ EVENT_WORKER_VERIFIER_TEMPLATE_ID,
5198
+ BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID
4619
5199
  };