@hasna/loops 0.3.15 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -328,6 +328,17 @@ function optionalPositiveInteger(value, label) {
328
328
  throw new Error(`${label} must be a positive integer`);
329
329
  return value;
330
330
  }
331
+ function optionalStringArray(value, label) {
332
+ if (value === undefined)
333
+ return;
334
+ if (!Array.isArray(value))
335
+ throw new Error(`${label} must be an array`);
336
+ const values = value.map((entry, index) => {
337
+ assertString(entry, `${label}[${index}]`);
338
+ return entry.trim();
339
+ }).filter(Boolean);
340
+ return values.length ? values : undefined;
341
+ }
331
342
  function normalizeGoalSpec(value, label = "goal") {
332
343
  if (value === undefined)
333
344
  return;
@@ -399,6 +410,14 @@ function validateTarget(value, label) {
399
410
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
400
411
  }
401
412
  }
413
+ if (value.allowlist !== undefined) {
414
+ assertObject(value.allowlist, `${label}.allowlist`);
415
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
416
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
417
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
418
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
419
+ }
420
+ }
402
421
  return value;
403
422
  }
404
423
  throw new Error(`${label}.type must be command or agent`);
@@ -2152,7 +2171,7 @@ class Store {
2152
2171
  }
2153
2172
 
2154
2173
  // src/cli/index.ts
2155
- import { createHash } from "crypto";
2174
+ import { createHash as createHash2 } from "crypto";
2156
2175
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2157
2176
  import { Command } from "commander";
2158
2177
 
@@ -2571,6 +2590,16 @@ function metadataEnv(metadata) {
2571
2590
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2572
2591
  return env;
2573
2592
  }
2593
+ function allowlistEnv(allowlist) {
2594
+ const env = {};
2595
+ if (allowlist?.tools?.length)
2596
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2597
+ if (allowlist?.commands?.length)
2598
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2599
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2600
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2601
+ return env;
2602
+ }
2574
2603
  function providerCommand(provider) {
2575
2604
  switch (provider) {
2576
2605
  case "claude":
@@ -2778,7 +2807,8 @@ function commandSpec(target) {
2778
2807
  account: agentTarget.account,
2779
2808
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2780
2809
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2781
- stdin: agentTarget.prompt
2810
+ stdin: agentTarget.prompt,
2811
+ allowlist: agentTarget.allowlist
2782
2812
  };
2783
2813
  }
2784
2814
  function executionEnv(spec, metadata, opts) {
@@ -2790,6 +2820,7 @@ function executionEnv(spec, metadata, opts) {
2790
2820
  Object.assign(env, accountEnv);
2791
2821
  }
2792
2822
  Object.assign(env, spec.env ?? {});
2823
+ Object.assign(env, allowlistEnv(spec.allowlist));
2793
2824
  env.PATH = normalizeExecutionPath(env);
2794
2825
  Object.assign(env, metadataEnv(metadata));
2795
2826
  return env;
@@ -2828,6 +2859,9 @@ function remoteBootstrapLines(spec, metadata) {
2828
2859
  continue;
2829
2860
  lines.push(`export ${key}=${shellQuote(value)}`);
2830
2861
  }
2862
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2863
+ lines.push(`export ${key}=${shellQuote(value)}`);
2864
+ }
2831
2865
  return lines;
2832
2866
  }
2833
2867
  function remoteScript(spec, metadata) {
@@ -4577,10 +4611,220 @@ function runDoctor(store) {
4577
4611
  checks
4578
4612
  };
4579
4613
  }
4614
+
4615
+ // src/lib/health.ts
4616
+ import { createHash } from "crypto";
4617
+ var EVIDENCE_CHARS = 2000;
4618
+ var CLASSIFICATIONS = [
4619
+ "rate_limit",
4620
+ "auth",
4621
+ "model_not_found",
4622
+ "context_length",
4623
+ "schema_response_format",
4624
+ "node_init",
4625
+ "timeout",
4626
+ "sigsegv",
4627
+ "skipped_previous_active",
4628
+ "unknown"
4629
+ ];
4630
+ function bounded(value, limit = EVIDENCE_CHARS) {
4631
+ if (!value)
4632
+ return;
4633
+ if (value.length <= limit)
4634
+ return value;
4635
+ return `${value.slice(0, limit)}
4636
+ [truncated ${value.length - limit} chars]`;
4637
+ }
4638
+ function searchableText(run) {
4639
+ return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
4640
+ `).toLowerCase();
4641
+ }
4642
+ function stableFingerprint(parts) {
4643
+ return createHash("sha256").update(parts.join(`
4644
+ `)).digest("hex").slice(0, 16);
4645
+ }
4646
+ function healthRun(run) {
4647
+ return {
4648
+ ...run,
4649
+ error: bounded(run.error),
4650
+ stdout: bounded(run.stdout),
4651
+ stderr: bounded(run.stderr)
4652
+ };
4653
+ }
4654
+ function classifyRunFailure(run) {
4655
+ if (run.status === "succeeded" || run.status === "running")
4656
+ return;
4657
+ const text = searchableText(run);
4658
+ let classification = "unknown";
4659
+ if (run.status === "timed_out")
4660
+ classification = "timeout";
4661
+ else if (run.status === "skipped" && /previous run still active/.test(text))
4662
+ classification = "skipped_previous_active";
4663
+ else if (/rate limit|too many requests|429\b|quota exceeded/.test(text))
4664
+ classification = "rate_limit";
4665
+ else if (/unauthorized|authentication|auth\b|api key|invalid token|permission denied|401\b|403\b/.test(text))
4666
+ classification = "auth";
4667
+ else if (/model .*not found|model_not_found|unknown model|invalid model|404.*model/.test(text))
4668
+ classification = "model_not_found";
4669
+ else if (/context length|context_length|context window|maximum context|token limit|too many tokens/.test(text))
4670
+ classification = "context_length";
4671
+ else if (/response_format|json schema|schema validation|invalid schema|structured output/.test(text))
4672
+ classification = "schema_response_format";
4673
+ else if (/cannot find module|module not found|node:internal|bun: command not found|node: command not found|npm err!|err_module_not_found/.test(text))
4674
+ classification = "node_init";
4675
+ else if (/sigsegv|segmentation fault|signal 11/.test(text))
4676
+ classification = "sigsegv";
4677
+ return {
4678
+ classification,
4679
+ fingerprint: stableFingerprint([
4680
+ run.loopId,
4681
+ run.loopName,
4682
+ run.status,
4683
+ classification,
4684
+ String(run.exitCode ?? ""),
4685
+ (run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
4686
+ ]),
4687
+ evidence: {
4688
+ error: bounded(run.error),
4689
+ stdout: bounded(run.stdout),
4690
+ stderr: bounded(run.stderr),
4691
+ exitCode: run.exitCode
4692
+ }
4693
+ };
4694
+ }
4695
+ function targetRoute(loop) {
4696
+ if (loop.target.type === "agent") {
4697
+ return {
4698
+ source: "openloops",
4699
+ kind: "loop_expectation",
4700
+ loopId: loop.id,
4701
+ loopName: loop.name,
4702
+ cwd: loop.target.cwd,
4703
+ provider: loop.target.provider
4704
+ };
4705
+ }
4706
+ if (loop.target.type === "command") {
4707
+ return {
4708
+ source: "openloops",
4709
+ kind: "loop_expectation",
4710
+ loopId: loop.id,
4711
+ loopName: loop.name,
4712
+ cwd: loop.target.cwd
4713
+ };
4714
+ }
4715
+ return {
4716
+ source: "openloops",
4717
+ kind: "loop_expectation",
4718
+ loopId: loop.id,
4719
+ loopName: loop.name
4720
+ };
4721
+ }
4722
+ function recommendedTask(loop, run, failure, route) {
4723
+ const title = `BUG: open-loops loop failure - ${loop.name}`;
4724
+ const description = [
4725
+ `OpenLoops expectation failed for loop ${loop.name} (${loop.id}).`,
4726
+ `Run: ${run.id}`,
4727
+ `Status: ${run.status}`,
4728
+ `Classification: ${failure.classification}`,
4729
+ `Fingerprint: ${failure.fingerprint}`,
4730
+ route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4731
+ route.provider ? `Provider: ${route.provider}` : undefined,
4732
+ failure.evidence.error ? `Error:
4733
+ ${failure.evidence.error}` : undefined,
4734
+ failure.evidence.stderr ? `Stderr:
4735
+ ${failure.evidence.stderr}` : undefined
4736
+ ].filter(Boolean).join(`
4737
+
4738
+ `);
4739
+ const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
4740
+ const tags = ["bug", "openloops", "loop-health", failure.classification];
4741
+ const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
4742
+ return {
4743
+ title,
4744
+ description,
4745
+ priority,
4746
+ tags,
4747
+ dedupeKey,
4748
+ search: { query: dedupeKey },
4749
+ compatibilityFallback: {
4750
+ search: ["todos", "search", dedupeKey, "--json"],
4751
+ add: ["todos", "add", title, "--description", description, "--tag", tags.join(","), "--priority", priority],
4752
+ comment: ["todos", "comment", "<task-id>", description]
4753
+ },
4754
+ futureNativeUpsert: {
4755
+ command: "todos upsert",
4756
+ fields: {
4757
+ title,
4758
+ description,
4759
+ priority,
4760
+ tags,
4761
+ dedupeKey,
4762
+ routeSource: route.source,
4763
+ routeKind: route.kind,
4764
+ routeLoopId: route.loopId,
4765
+ routeLoopName: route.loopName
4766
+ }
4767
+ }
4768
+ };
4769
+ }
4770
+ function expectationForLoop(store, loop) {
4771
+ const latestRun = store.listRuns({ loopId: loop.id, limit: 1 })[0];
4772
+ const route = targetRoute(loop);
4773
+ if (!latestRun) {
4774
+ return {
4775
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4776
+ ok: true,
4777
+ check: { id: "latest-run-succeeded", status: "warn", message: "loop has no recorded runs yet" },
4778
+ route
4779
+ };
4780
+ }
4781
+ if (latestRun.status === "succeeded") {
4782
+ return {
4783
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4784
+ ok: true,
4785
+ check: { id: "latest-run-succeeded", status: "pass", message: "latest run succeeded" },
4786
+ latestRun: healthRun(latestRun),
4787
+ route
4788
+ };
4789
+ }
4790
+ const failure = classifyRunFailure(latestRun);
4791
+ return {
4792
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4793
+ ok: false,
4794
+ check: { id: "latest-run-succeeded", status: "fail", message: `latest run is ${latestRun.status}` },
4795
+ latestRun: healthRun(latestRun),
4796
+ failure,
4797
+ route,
4798
+ recommendedTask: failure ? recommendedTask(loop, latestRun, failure, route) : undefined
4799
+ };
4800
+ }
4801
+ function buildHealthReport(store, opts = {}) {
4802
+ const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4803
+ const expectations = loops.map((loop) => expectationForLoop(store, loop));
4804
+ const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4805
+ for (const expectation of expectations) {
4806
+ if (expectation.failure)
4807
+ classifications[expectation.failure.classification] += 1;
4808
+ }
4809
+ const unhealthy = expectations.filter((expectation) => !expectation.ok).length;
4810
+ const warnings = expectations.filter((expectation) => expectation.check.status === "warn").length;
4811
+ return {
4812
+ ok: unhealthy === 0,
4813
+ generatedAt: new Date().toISOString(),
4814
+ summary: {
4815
+ loops: expectations.length,
4816
+ healthy: expectations.length - unhealthy,
4817
+ unhealthy,
4818
+ warnings
4819
+ },
4820
+ classifications,
4821
+ expectations
4822
+ };
4823
+ }
4580
4824
  // package.json
4581
4825
  var package_default = {
4582
4826
  name: "@hasna/loops",
4583
- version: "0.3.15",
4827
+ version: "0.3.16",
4584
4828
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4585
4829
  type: "module",
4586
4830
  main: "dist/index.js",
@@ -5080,6 +5324,17 @@ function splitList(value) {
5080
5324
  const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
5081
5325
  return values?.length ? values : undefined;
5082
5326
  }
5327
+ function allowlistFromOpts(opts) {
5328
+ const tools = (opts.allowTool ?? []).flatMap((entry) => splitList(entry) ?? []);
5329
+ const commands = (opts.allowCommand ?? []).flatMap((entry) => splitList(entry) ?? []);
5330
+ if (!tools.length && !commands.length)
5331
+ return;
5332
+ return {
5333
+ tools: tools.length ? tools : undefined,
5334
+ commands: commands.length ? commands : undefined,
5335
+ enforcement: "metadata_only"
5336
+ };
5337
+ }
5083
5338
  function accountPoolFromOpts(opts) {
5084
5339
  return splitList(opts.accountPool)?.map((profile) => ({ profile, tool: opts.accountTool }));
5085
5340
  }
@@ -5119,7 +5374,7 @@ function slugSegment(value, fallback = "event") {
5119
5374
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
5120
5375
  }
5121
5376
  function stableSuffix(value) {
5122
- return createHash("sha256").update(value).digest("hex").slice(0, 12);
5377
+ return createHash2("sha256").update(value).digest("hex").slice(0, 12);
5123
5378
  }
5124
5379
  function taskEventField(data, keys) {
5125
5380
  for (const key of keys) {
@@ -5145,6 +5400,85 @@ function taskEventField(data, keys) {
5145
5400
  }
5146
5401
  return;
5147
5402
  }
5403
+ function objectField(value) {
5404
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
5405
+ }
5406
+ function nestedObject(input, key) {
5407
+ return objectField(input[key]);
5408
+ }
5409
+ function taskEventRecords(data, metadata) {
5410
+ const records = [data];
5411
+ const dataTask = nestedObject(data, "task");
5412
+ if (dataTask)
5413
+ records.push(dataTask);
5414
+ const dataPayload = nestedObject(data, "payload");
5415
+ if (dataPayload) {
5416
+ records.push(dataPayload);
5417
+ const payloadTask = nestedObject(dataPayload, "task");
5418
+ if (payloadTask)
5419
+ records.push(payloadTask);
5420
+ }
5421
+ const dataMetadata = nestedObject(data, "metadata");
5422
+ if (dataMetadata)
5423
+ records.push(dataMetadata);
5424
+ records.push(metadata);
5425
+ const metadataTask = nestedObject(metadata, "task");
5426
+ if (metadataTask)
5427
+ records.push(metadataTask);
5428
+ const metadataAutomation = nestedObject(metadata, "automation");
5429
+ if (metadataAutomation)
5430
+ records.push(metadataAutomation);
5431
+ return records;
5432
+ }
5433
+ function booleanLike(value) {
5434
+ return value === true || value === "true" || value === "1" || value === 1;
5435
+ }
5436
+ function hasTruthyField(records, keys) {
5437
+ return records.some((record) => keys.some((key) => booleanLike(record[key])));
5438
+ }
5439
+ function tagsFromValue(value) {
5440
+ if (Array.isArray(value))
5441
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
5442
+ if (typeof value === "string")
5443
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
5444
+ return [];
5445
+ }
5446
+ function taskEventTags(records) {
5447
+ const tags = new Set;
5448
+ for (const record of records) {
5449
+ for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags))
5450
+ tags.add(tag);
5451
+ }
5452
+ return [...tags];
5453
+ }
5454
+ function taskRouteEligibility(data, metadata) {
5455
+ const records = taskEventRecords(data, metadata);
5456
+ const tags = taskEventTags(records);
5457
+ const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed", "allowed"]);
5458
+ if (!hasRouteOptIn)
5459
+ return { eligible: false, reason: "missing explicit route opt-in", tags };
5460
+ const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
5461
+ if (status && ["blocked", "completed", "done", "cancelled", "canceled", "failed", "archived"].includes(status)) {
5462
+ return { eligible: false, reason: `task status is not routable: ${status}`, tags };
5463
+ }
5464
+ const disallowedTags = tags.filter((tag) => ["no-auto", "manual", "manual-required", "approval-required"].includes(tag));
5465
+ if (disallowedTags.length)
5466
+ return { eligible: false, reason: `task has disallowed tag: ${disallowedTags[0]}`, tags };
5467
+ if (hasTruthyField(records, [
5468
+ "no_auto",
5469
+ "noAuto",
5470
+ "manual",
5471
+ "manual_required",
5472
+ "manualRequired",
5473
+ "requires_approval",
5474
+ "requiresApproval",
5475
+ "approval_required",
5476
+ "approvalRequired"
5477
+ ])) {
5478
+ return { eligible: false, reason: "task metadata requires manual or approval-gated handling", tags };
5479
+ }
5480
+ return { eligible: true, tags };
5481
+ }
5148
5482
  async function readEventEnvelopeFromStdin() {
5149
5483
  const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
5150
5484
  const event = JSON.parse(raw);
@@ -5217,7 +5551,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5217
5551
  store.close();
5218
5552
  }
5219
5553
  });
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) => {
5554
+ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe"))))).action((name, opts) => {
5221
5555
  const provider = opts.provider;
5222
5556
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
5223
5557
  throw new Error("unsupported provider");
@@ -5240,6 +5574,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5240
5574
  configIsolation: opts.configIsolation,
5241
5575
  permissionMode: permissionModeFromOpts(opts, provider),
5242
5576
  sandbox: sandboxFromOpts(opts, provider),
5577
+ allowlist: allowlistFromOpts(opts),
5243
5578
  account: accountFromOpts(opts)
5244
5579
  };
5245
5580
  const loop = store.createLoop(baseCreateInput(name, opts, target));
@@ -5305,6 +5640,11 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5305
5640
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
5306
5641
  if (!taskId)
5307
5642
  throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
5643
+ const eligibility = taskRouteEligibility(data, metadata);
5644
+ if (!eligibility.eligible) {
5645
+ print({ skipped: true, reason: eligibility.reason, event, taskId, eligibility }, `skipped task ${taskId}: ${eligibility.reason}`);
5646
+ return;
5647
+ }
5308
5648
  const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
5309
5649
  const taskDescription = taskEventField(data, ["description", "body"]);
5310
5650
  const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
@@ -5729,6 +6069,44 @@ program.command("runs [idOrName]").option("--limit <n>", "limit", "50").option("
5729
6069
  store.close();
5730
6070
  }
5731
6071
  });
6072
+ program.command("expectations [idOrName]").description("evaluate deterministic loop expectations without mutating external task systems").option("--limit <n>", "maximum loops to inspect when no loop is specified", "200").option("--json", "print JSON").action((idOrName, opts) => {
6073
+ const store = new Store;
6074
+ try {
6075
+ const loops = idOrName ? [store.requireLoop(idOrName)] : store.listLoops({ limit: Number(opts.limit) });
6076
+ const values = loops.map((loop) => expectationForLoop(store, loop));
6077
+ if (isJson() || opts.json)
6078
+ console.log(JSON.stringify(idOrName ? values[0] : values, null, 2));
6079
+ else {
6080
+ for (const value of values) {
6081
+ console.log(`${value.ok ? "ok" : "fail"} ${value.loop.name} ${value.check.message}`);
6082
+ if (value.failure)
6083
+ console.log(` classification=${value.failure.classification} fingerprint=${value.failure.fingerprint}`);
6084
+ }
6085
+ }
6086
+ if (values.some((value) => !value.ok))
6087
+ process.exitCode = 1;
6088
+ } finally {
6089
+ store.close();
6090
+ }
6091
+ });
6092
+ program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
6093
+ const store = new Store;
6094
+ try {
6095
+ const report = buildHealthReport(store);
6096
+ if (isJson() || opts.json)
6097
+ console.log(JSON.stringify(report, null, 2));
6098
+ else {
6099
+ console.log(`loops=${report.summary.loops} healthy=${report.summary.healthy} unhealthy=${report.summary.unhealthy} warnings=${report.summary.warnings}`);
6100
+ for (const expectation of report.expectations.filter((entry) => !entry.ok)) {
6101
+ console.log(`fail ${expectation.loop.name} ${expectation.failure?.classification ?? "unknown"} ${expectation.failure?.fingerprint ?? "-"}`);
6102
+ }
6103
+ }
6104
+ if (!report.ok)
6105
+ process.exitCode = 1;
6106
+ } finally {
6107
+ store.close();
6108
+ }
6109
+ });
5732
6110
  program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
5733
6111
  program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
5734
6112
  program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
@@ -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`);
@@ -2465,6 +2484,16 @@ function metadataEnv(metadata) {
2465
2484
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2466
2485
  return env;
2467
2486
  }
2487
+ function allowlistEnv(allowlist) {
2488
+ const env = {};
2489
+ if (allowlist?.tools?.length)
2490
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2491
+ if (allowlist?.commands?.length)
2492
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2493
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2494
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2495
+ return env;
2496
+ }
2468
2497
  function providerCommand(provider) {
2469
2498
  switch (provider) {
2470
2499
  case "claude":
@@ -2672,7 +2701,8 @@ function commandSpec(target) {
2672
2701
  account: agentTarget.account,
2673
2702
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2674
2703
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2675
- stdin: agentTarget.prompt
2704
+ stdin: agentTarget.prompt,
2705
+ allowlist: agentTarget.allowlist
2676
2706
  };
2677
2707
  }
2678
2708
  function executionEnv(spec, metadata, opts) {
@@ -2684,6 +2714,7 @@ function executionEnv(spec, metadata, opts) {
2684
2714
  Object.assign(env, accountEnv);
2685
2715
  }
2686
2716
  Object.assign(env, spec.env ?? {});
2717
+ Object.assign(env, allowlistEnv(spec.allowlist));
2687
2718
  env.PATH = normalizeExecutionPath(env);
2688
2719
  Object.assign(env, metadataEnv(metadata));
2689
2720
  return env;
@@ -2722,6 +2753,9 @@ function remoteBootstrapLines(spec, metadata) {
2722
2753
  continue;
2723
2754
  lines.push(`export ${key}=${shellQuote(value)}`);
2724
2755
  }
2756
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2757
+ lines.push(`export ${key}=${shellQuote(value)}`);
2758
+ }
2725
2759
  return lines;
2726
2760
  }
2727
2761
  function remoteScript(spec, metadata) {
@@ -4361,7 +4395,7 @@ function enableStartup(result) {
4361
4395
  // package.json
4362
4396
  var package_default = {
4363
4397
  name: "@hasna/loops",
4364
- version: "0.3.15",
4398
+ version: "0.3.16",
4365
4399
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4366
4400
  type: "module",
4367
4401
  main: "dist/index.js",
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { executeWorkflow, executeLoopTarget, preflightWorkflow } from "./lib/wor
10
10
  export { workflowExecutionOrder, workflowBodyFromJson } from "./lib/workflow-spec.js";
11
11
  export { EVENT_WORKER_VERIFIER_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, getLoopTemplate, listLoopTemplates, renderEventWorkerVerifierWorkflow, renderLoopTemplate, renderTodosTaskWorkerVerifierWorkflow, } from "./lib/templates.js";
12
12
  export { runDoctor } from "./lib/doctor.js";
13
+ export { buildHealthReport, classifyRunFailure, expectationForLoop } from "./lib/health.js";
13
14
  export { runGoal } from "./lib/goal/runner.js";
14
15
  export { resolveGoalModel } from "./lib/goal/model-factory.js";
15
16
  export { isTerminal as isGoalTerminal, readyNodeKeys, rollupSummary } from "./lib/goal/status.js";
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`);
@@ -2455,6 +2474,16 @@ function metadataEnv(metadata) {
2455
2474
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2456
2475
  return env;
2457
2476
  }
2477
+ function allowlistEnv(allowlist) {
2478
+ const env = {};
2479
+ if (allowlist?.tools?.length)
2480
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2481
+ if (allowlist?.commands?.length)
2482
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2483
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2484
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2485
+ return env;
2486
+ }
2458
2487
  function providerCommand(provider) {
2459
2488
  switch (provider) {
2460
2489
  case "claude":
@@ -2662,7 +2691,8 @@ function commandSpec(target) {
2662
2691
  account: agentTarget.account,
2663
2692
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2664
2693
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2665
- stdin: agentTarget.prompt
2694
+ stdin: agentTarget.prompt,
2695
+ allowlist: agentTarget.allowlist
2666
2696
  };
2667
2697
  }
2668
2698
  function executionEnv(spec, metadata, opts) {
@@ -2674,6 +2704,7 @@ function executionEnv(spec, metadata, opts) {
2674
2704
  Object.assign(env, accountEnv);
2675
2705
  }
2676
2706
  Object.assign(env, spec.env ?? {});
2707
+ Object.assign(env, allowlistEnv(spec.allowlist));
2677
2708
  env.PATH = normalizeExecutionPath(env);
2678
2709
  Object.assign(env, metadataEnv(metadata));
2679
2710
  return env;
@@ -2712,6 +2743,9 @@ function remoteBootstrapLines(spec, metadata) {
2712
2743
  continue;
2713
2744
  lines.push(`export ${key}=${shellQuote(value)}`);
2714
2745
  }
2746
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2747
+ lines.push(`export ${key}=${shellQuote(value)}`);
2748
+ }
2715
2749
  return lines;
2716
2750
  }
2717
2751
  function remoteScript(spec, metadata) {
@@ -4582,6 +4616,215 @@ function runDoctor(store) {
4582
4616
  checks
4583
4617
  };
4584
4618
  }
4619
+ // src/lib/health.ts
4620
+ import { createHash } from "crypto";
4621
+ var EVIDENCE_CHARS = 2000;
4622
+ var CLASSIFICATIONS = [
4623
+ "rate_limit",
4624
+ "auth",
4625
+ "model_not_found",
4626
+ "context_length",
4627
+ "schema_response_format",
4628
+ "node_init",
4629
+ "timeout",
4630
+ "sigsegv",
4631
+ "skipped_previous_active",
4632
+ "unknown"
4633
+ ];
4634
+ function bounded(value, limit = EVIDENCE_CHARS) {
4635
+ if (!value)
4636
+ return;
4637
+ if (value.length <= limit)
4638
+ return value;
4639
+ return `${value.slice(0, limit)}
4640
+ [truncated ${value.length - limit} chars]`;
4641
+ }
4642
+ function searchableText(run) {
4643
+ return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
4644
+ `).toLowerCase();
4645
+ }
4646
+ function stableFingerprint(parts) {
4647
+ return createHash("sha256").update(parts.join(`
4648
+ `)).digest("hex").slice(0, 16);
4649
+ }
4650
+ function healthRun(run) {
4651
+ return {
4652
+ ...run,
4653
+ error: bounded(run.error),
4654
+ stdout: bounded(run.stdout),
4655
+ stderr: bounded(run.stderr)
4656
+ };
4657
+ }
4658
+ function classifyRunFailure(run) {
4659
+ if (run.status === "succeeded" || run.status === "running")
4660
+ return;
4661
+ const text = searchableText(run);
4662
+ let classification = "unknown";
4663
+ if (run.status === "timed_out")
4664
+ classification = "timeout";
4665
+ else if (run.status === "skipped" && /previous run still active/.test(text))
4666
+ classification = "skipped_previous_active";
4667
+ else if (/rate limit|too many requests|429\b|quota exceeded/.test(text))
4668
+ classification = "rate_limit";
4669
+ else if (/unauthorized|authentication|auth\b|api key|invalid token|permission denied|401\b|403\b/.test(text))
4670
+ classification = "auth";
4671
+ else if (/model .*not found|model_not_found|unknown model|invalid model|404.*model/.test(text))
4672
+ classification = "model_not_found";
4673
+ else if (/context length|context_length|context window|maximum context|token limit|too many tokens/.test(text))
4674
+ classification = "context_length";
4675
+ else if (/response_format|json schema|schema validation|invalid schema|structured output/.test(text))
4676
+ classification = "schema_response_format";
4677
+ 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))
4678
+ classification = "node_init";
4679
+ else if (/sigsegv|segmentation fault|signal 11/.test(text))
4680
+ classification = "sigsegv";
4681
+ return {
4682
+ classification,
4683
+ fingerprint: stableFingerprint([
4684
+ run.loopId,
4685
+ run.loopName,
4686
+ run.status,
4687
+ classification,
4688
+ String(run.exitCode ?? ""),
4689
+ (run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
4690
+ ]),
4691
+ evidence: {
4692
+ error: bounded(run.error),
4693
+ stdout: bounded(run.stdout),
4694
+ stderr: bounded(run.stderr),
4695
+ exitCode: run.exitCode
4696
+ }
4697
+ };
4698
+ }
4699
+ function targetRoute(loop) {
4700
+ if (loop.target.type === "agent") {
4701
+ return {
4702
+ source: "openloops",
4703
+ kind: "loop_expectation",
4704
+ loopId: loop.id,
4705
+ loopName: loop.name,
4706
+ cwd: loop.target.cwd,
4707
+ provider: loop.target.provider
4708
+ };
4709
+ }
4710
+ if (loop.target.type === "command") {
4711
+ return {
4712
+ source: "openloops",
4713
+ kind: "loop_expectation",
4714
+ loopId: loop.id,
4715
+ loopName: loop.name,
4716
+ cwd: loop.target.cwd
4717
+ };
4718
+ }
4719
+ return {
4720
+ source: "openloops",
4721
+ kind: "loop_expectation",
4722
+ loopId: loop.id,
4723
+ loopName: loop.name
4724
+ };
4725
+ }
4726
+ function recommendedTask(loop, run, failure, route) {
4727
+ const title = `BUG: open-loops loop failure - ${loop.name}`;
4728
+ const description = [
4729
+ `OpenLoops expectation failed for loop ${loop.name} (${loop.id}).`,
4730
+ `Run: ${run.id}`,
4731
+ `Status: ${run.status}`,
4732
+ `Classification: ${failure.classification}`,
4733
+ `Fingerprint: ${failure.fingerprint}`,
4734
+ route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4735
+ route.provider ? `Provider: ${route.provider}` : undefined,
4736
+ failure.evidence.error ? `Error:
4737
+ ${failure.evidence.error}` : undefined,
4738
+ failure.evidence.stderr ? `Stderr:
4739
+ ${failure.evidence.stderr}` : undefined
4740
+ ].filter(Boolean).join(`
4741
+
4742
+ `);
4743
+ const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
4744
+ const tags = ["bug", "openloops", "loop-health", failure.classification];
4745
+ const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
4746
+ return {
4747
+ title,
4748
+ description,
4749
+ priority,
4750
+ tags,
4751
+ dedupeKey,
4752
+ search: { query: dedupeKey },
4753
+ compatibilityFallback: {
4754
+ search: ["todos", "search", dedupeKey, "--json"],
4755
+ add: ["todos", "add", title, "--description", description, "--tag", tags.join(","), "--priority", priority],
4756
+ comment: ["todos", "comment", "<task-id>", description]
4757
+ },
4758
+ futureNativeUpsert: {
4759
+ command: "todos upsert",
4760
+ fields: {
4761
+ title,
4762
+ description,
4763
+ priority,
4764
+ tags,
4765
+ dedupeKey,
4766
+ routeSource: route.source,
4767
+ routeKind: route.kind,
4768
+ routeLoopId: route.loopId,
4769
+ routeLoopName: route.loopName
4770
+ }
4771
+ }
4772
+ };
4773
+ }
4774
+ function expectationForLoop(store, loop) {
4775
+ const latestRun = store.listRuns({ loopId: loop.id, limit: 1 })[0];
4776
+ const route = targetRoute(loop);
4777
+ if (!latestRun) {
4778
+ return {
4779
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4780
+ ok: true,
4781
+ check: { id: "latest-run-succeeded", status: "warn", message: "loop has no recorded runs yet" },
4782
+ route
4783
+ };
4784
+ }
4785
+ if (latestRun.status === "succeeded") {
4786
+ return {
4787
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4788
+ ok: true,
4789
+ check: { id: "latest-run-succeeded", status: "pass", message: "latest run succeeded" },
4790
+ latestRun: healthRun(latestRun),
4791
+ route
4792
+ };
4793
+ }
4794
+ const failure = classifyRunFailure(latestRun);
4795
+ return {
4796
+ loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
4797
+ ok: false,
4798
+ check: { id: "latest-run-succeeded", status: "fail", message: `latest run is ${latestRun.status}` },
4799
+ latestRun: healthRun(latestRun),
4800
+ failure,
4801
+ route,
4802
+ recommendedTask: failure ? recommendedTask(loop, latestRun, failure, route) : undefined
4803
+ };
4804
+ }
4805
+ function buildHealthReport(store, opts = {}) {
4806
+ const loops2 = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4807
+ const expectations = loops2.map((loop) => expectationForLoop(store, loop));
4808
+ const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4809
+ for (const expectation of expectations) {
4810
+ if (expectation.failure)
4811
+ classifications[expectation.failure.classification] += 1;
4812
+ }
4813
+ const unhealthy = expectations.filter((expectation) => !expectation.ok).length;
4814
+ const warnings = expectations.filter((expectation) => expectation.check.status === "warn").length;
4815
+ return {
4816
+ ok: unhealthy === 0,
4817
+ generatedAt: new Date().toISOString(),
4818
+ summary: {
4819
+ loops: expectations.length,
4820
+ healthy: expectations.length - unhealthy,
4821
+ unhealthy,
4822
+ warnings
4823
+ },
4824
+ classifications,
4825
+ expectations
4826
+ };
4827
+ }
4585
4828
  export {
4586
4829
  workflowExecutionOrder,
4587
4830
  workflowBodyFromJson,
@@ -4607,11 +4850,14 @@ export {
4607
4850
  isTerminal as isGoalTerminal,
4608
4851
  initialNextRun,
4609
4852
  getLoopTemplate,
4853
+ expectationForLoop,
4610
4854
  executeWorkflow,
4611
4855
  executeTarget,
4612
4856
  executeLoopTarget,
4613
4857
  executeLoop,
4614
4858
  computeNextAfter,
4859
+ classifyRunFailure,
4860
+ buildHealthReport,
4615
4861
  TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
4616
4862
  Store,
4617
4863
  LoopsClient,
@@ -0,0 +1,70 @@
1
+ import type { Loop, LoopRun } from "../types.js";
2
+ import type { Store } from "./store.js";
3
+ export type RunFailureClassification = "rate_limit" | "auth" | "model_not_found" | "context_length" | "schema_response_format" | "node_init" | "timeout" | "sigsegv" | "skipped_previous_active" | "unknown";
4
+ export interface RunFailureSignal {
5
+ classification: RunFailureClassification;
6
+ fingerprint: string;
7
+ evidence: {
8
+ error?: string;
9
+ stdout?: string;
10
+ stderr?: string;
11
+ exitCode?: number;
12
+ };
13
+ }
14
+ export interface RecommendedTaskUpsert {
15
+ title: string;
16
+ description: string;
17
+ priority: "critical" | "high" | "medium" | "low";
18
+ tags: string[];
19
+ dedupeKey: string;
20
+ search: {
21
+ query: string;
22
+ };
23
+ compatibilityFallback: {
24
+ search: string[];
25
+ add: string[];
26
+ comment: string[];
27
+ };
28
+ futureNativeUpsert: {
29
+ command: string;
30
+ fields: Record<string, string | string[]>;
31
+ };
32
+ }
33
+ export interface LoopExpectationResult {
34
+ loop: Pick<Loop, "id" | "name" | "status" | "nextRunAt">;
35
+ ok: boolean;
36
+ check: {
37
+ id: "latest-run-succeeded";
38
+ status: "pass" | "fail" | "warn";
39
+ message: string;
40
+ };
41
+ latestRun?: LoopRun;
42
+ failure?: RunFailureSignal;
43
+ route: {
44
+ source: "openloops";
45
+ kind: "loop_expectation";
46
+ loopId: string;
47
+ loopName: string;
48
+ cwd?: string;
49
+ provider?: string;
50
+ };
51
+ recommendedTask?: RecommendedTaskUpsert;
52
+ }
53
+ export interface LoopsHealthReport {
54
+ ok: boolean;
55
+ generatedAt: string;
56
+ summary: {
57
+ loops: number;
58
+ healthy: number;
59
+ unhealthy: number;
60
+ warnings: number;
61
+ };
62
+ classifications: Record<RunFailureClassification, number>;
63
+ expectations: LoopExpectationResult[];
64
+ }
65
+ export declare function classifyRunFailure(run: LoopRun): RunFailureSignal | undefined;
66
+ export declare function expectationForLoop(store: Store, loop: Loop): LoopExpectationResult;
67
+ export declare function buildHealthReport(store: Store, opts?: {
68
+ includeArchived?: boolean;
69
+ limit?: number;
70
+ }): LoopsHealthReport;
package/dist/lib/store.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`);
package/dist/sdk/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`);
@@ -2455,6 +2474,16 @@ function metadataEnv(metadata) {
2455
2474
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2456
2475
  return env;
2457
2476
  }
2477
+ function allowlistEnv(allowlist) {
2478
+ const env = {};
2479
+ if (allowlist?.tools?.length)
2480
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2481
+ if (allowlist?.commands?.length)
2482
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2483
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2484
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2485
+ return env;
2486
+ }
2458
2487
  function providerCommand(provider) {
2459
2488
  switch (provider) {
2460
2489
  case "claude":
@@ -2662,7 +2691,8 @@ function commandSpec(target) {
2662
2691
  account: agentTarget.account,
2663
2692
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2664
2693
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2665
- stdin: agentTarget.prompt
2694
+ stdin: agentTarget.prompt,
2695
+ allowlist: agentTarget.allowlist
2666
2696
  };
2667
2697
  }
2668
2698
  function executionEnv(spec, metadata, opts) {
@@ -2674,6 +2704,7 @@ function executionEnv(spec, metadata, opts) {
2674
2704
  Object.assign(env, accountEnv);
2675
2705
  }
2676
2706
  Object.assign(env, spec.env ?? {});
2707
+ Object.assign(env, allowlistEnv(spec.allowlist));
2677
2708
  env.PATH = normalizeExecutionPath(env);
2678
2709
  Object.assign(env, metadataEnv(metadata));
2679
2710
  return env;
@@ -2712,6 +2743,9 @@ function remoteBootstrapLines(spec, metadata) {
2712
2743
  continue;
2713
2744
  lines.push(`export ${key}=${shellQuote(value)}`);
2714
2745
  }
2746
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2747
+ lines.push(`export ${key}=${shellQuote(value)}`);
2748
+ }
2715
2749
  return lines;
2716
2750
  }
2717
2751
  function remoteScript(spec, metadata) {
package/dist/types.d.ts CHANGED
@@ -54,6 +54,11 @@ export type AgentProvider = "claude" | "cursor" | "codewith" | "aicopilot" | "op
54
54
  export type AgentConfigIsolation = "safe" | "none";
55
55
  export type AgentPermissionMode = "default" | "plan" | "auto" | "bypass";
56
56
  export type AgentSandbox = "read-only" | "workspace-write" | "danger-full-access" | "enabled" | "disabled";
57
+ export interface AgentAllowlistSpec {
58
+ tools?: string[];
59
+ commands?: string[];
60
+ enforcement?: "metadata_only";
61
+ }
57
62
  export interface AgentTarget {
58
63
  type: "agent";
59
64
  provider: AgentProvider;
@@ -68,6 +73,7 @@ export interface AgentTarget {
68
73
  configIsolation?: AgentConfigIsolation;
69
74
  permissionMode?: AgentPermissionMode;
70
75
  sandbox?: AgentSandbox;
76
+ allowlist?: AgentAllowlistSpec;
71
77
  account?: AccountRef;
72
78
  }
73
79
  export interface WorkflowTarget {
package/docs/USAGE.md CHANGED
@@ -94,6 +94,23 @@ loops create agent supply-chain-watch \
94
94
  --prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
95
95
  ```
96
96
 
97
+ Agent loops can also carry advisory per-session allowlist metadata:
98
+
99
+ ```bash
100
+ loops create agent repo-check \
101
+ --provider codewith \
102
+ --every 15m \
103
+ --cwd /path/to/repo \
104
+ --prompt "Check the repo and report concrete failures." \
105
+ --allow-tool functions.exec_command \
106
+ --allow-command git,bun
107
+ ```
108
+
109
+ These fields are stored on the loop target and exposed to the run environment
110
+ as `LOOPS_AGENT_ALLOWED_TOOLS`, `LOOPS_AGENT_ALLOWED_COMMANDS`, and
111
+ `LOOPS_AGENT_ALLOWLIST_ENFORCEMENT=metadata_only`. They are not enforced by
112
+ OpenLoops yet; provider-native enforcement will be added separately.
113
+
97
114
  For `codewith` and `aicopilot` account isolation, register matching OpenAccounts tools first if they are not built in on the machine:
98
115
 
99
116
  ```bash
@@ -201,6 +218,14 @@ cat task-created-event.json | loops events handle todos-task \
201
218
  --sandbox danger-full-access
202
219
  ```
203
220
 
221
+ Task routing is explicit opt-in. The handler skips the event without creating a
222
+ workflow unless the event data or metadata has `route_enabled=true`,
223
+ `automation.allowed=true`, or a task tag containing `auto:route`. It also skips
224
+ blocked, completed/done, cancelled/canceled, failed, archived, manual,
225
+ approval-required, or `no-auto` tasks. This guard exists even when the upstream
226
+ `@hasna/events` webhook filter is misconfigured, so task existence alone is not
227
+ permission to execute agent work.
228
+
204
229
  For other Hasna apps that expose `@hasna/events` webhooks, use the generic
205
230
  handler:
206
231
 
@@ -250,6 +275,27 @@ loops run-now <id-or-name>
250
275
 
251
276
  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.
252
277
 
278
+ ## Health And Expectations
279
+
280
+ `loops health --json` summarizes the latest run for each loop and classifies
281
+ agent-run failures for default-loop SLOs:
282
+
283
+ ```bash
284
+ loops health --json
285
+ loops expectations <loop-id-or-name> --json
286
+ ```
287
+
288
+ The JSON contains the expectation result, bounded error/stdout/stderr evidence,
289
+ 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`.
294
+
295
+ Failure classifications are: `rate_limit`, `auth`, `model_not_found`,
296
+ `context_length`, `schema_response_format`, `node_init`, `timeout`, `sigsegv`,
297
+ `skipped_previous_active`, and `unknown`.
298
+
253
299
  Archive loops when retiring old automation but preserving history:
254
300
 
255
301
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
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",