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