@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 +383 -5
- package/dist/daemon/index.js +36 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +247 -1
- package/dist/lib/health.d.ts +70 -0
- package/dist/lib/store.js +19 -0
- package/dist/sdk/index.js +35 -1
- package/dist/types.d.ts +6 -0
- package/docs/USAGE.md +46 -0
- package/package.json +1 -1
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.
|
|
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
|
|
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"));
|
package/dist/daemon/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`);
|
|
@@ -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.
|
|
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