@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/index.js
CHANGED
|
@@ -326,6 +326,17 @@ function optionalPositiveInteger(value, label) {
|
|
|
326
326
|
throw new Error(`${label} must be a positive integer`);
|
|
327
327
|
return value;
|
|
328
328
|
}
|
|
329
|
+
function optionalStringArray(value, label) {
|
|
330
|
+
if (value === undefined)
|
|
331
|
+
return;
|
|
332
|
+
if (!Array.isArray(value))
|
|
333
|
+
throw new Error(`${label} must be an array`);
|
|
334
|
+
const values = value.map((entry, index) => {
|
|
335
|
+
assertString(entry, `${label}[${index}]`);
|
|
336
|
+
return entry.trim();
|
|
337
|
+
}).filter(Boolean);
|
|
338
|
+
return values.length ? values : undefined;
|
|
339
|
+
}
|
|
329
340
|
function normalizeGoalSpec(value, label = "goal") {
|
|
330
341
|
if (value === undefined)
|
|
331
342
|
return;
|
|
@@ -397,6 +408,14 @@ function validateTarget(value, label) {
|
|
|
397
408
|
throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
|
|
398
409
|
}
|
|
399
410
|
}
|
|
411
|
+
if (value.allowlist !== undefined) {
|
|
412
|
+
assertObject(value.allowlist, `${label}.allowlist`);
|
|
413
|
+
optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
|
|
414
|
+
optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
|
|
415
|
+
if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
|
|
416
|
+
throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
400
419
|
return value;
|
|
401
420
|
}
|
|
402
421
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -1031,6 +1050,30 @@ class Store {
|
|
|
1031
1050
|
throw new Error(`loop not found after update: ${id}`);
|
|
1032
1051
|
return after;
|
|
1033
1052
|
}
|
|
1053
|
+
renameLoop(id, name, opts = {}) {
|
|
1054
|
+
const current = this.getLoop(id);
|
|
1055
|
+
if (!current)
|
|
1056
|
+
throw new Error(`loop not found: ${id}`);
|
|
1057
|
+
const trimmed = name.trim();
|
|
1058
|
+
if (!trimmed)
|
|
1059
|
+
throw new Error("loop name must not be empty");
|
|
1060
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
1061
|
+
this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
|
|
1062
|
+
WHERE id=$id
|
|
1063
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1064
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1065
|
+
))`).run({
|
|
1066
|
+
$id: id,
|
|
1067
|
+
$name: trimmed,
|
|
1068
|
+
$updated: updated,
|
|
1069
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1070
|
+
$now: updated
|
|
1071
|
+
});
|
|
1072
|
+
const after = this.getLoop(id);
|
|
1073
|
+
if (!after)
|
|
1074
|
+
throw new Error(`loop not found after rename: ${id}`);
|
|
1075
|
+
return after;
|
|
1076
|
+
}
|
|
1034
1077
|
archiveLoop(idOrName) {
|
|
1035
1078
|
const loop = this.requireLoop(idOrName);
|
|
1036
1079
|
if (loop.archivedAt)
|
|
@@ -2455,6 +2498,16 @@ function metadataEnv(metadata) {
|
|
|
2455
2498
|
env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
|
|
2456
2499
|
return env;
|
|
2457
2500
|
}
|
|
2501
|
+
function allowlistEnv(allowlist) {
|
|
2502
|
+
const env = {};
|
|
2503
|
+
if (allowlist?.tools?.length)
|
|
2504
|
+
env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
|
|
2505
|
+
if (allowlist?.commands?.length)
|
|
2506
|
+
env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
|
|
2507
|
+
if (allowlist?.tools?.length || allowlist?.commands?.length)
|
|
2508
|
+
env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
|
|
2509
|
+
return env;
|
|
2510
|
+
}
|
|
2458
2511
|
function providerCommand(provider) {
|
|
2459
2512
|
switch (provider) {
|
|
2460
2513
|
case "claude":
|
|
@@ -2662,7 +2715,8 @@ function commandSpec(target) {
|
|
|
2662
2715
|
account: agentTarget.account,
|
|
2663
2716
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2664
2717
|
preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
|
|
2665
|
-
stdin: agentTarget.prompt
|
|
2718
|
+
stdin: agentTarget.prompt,
|
|
2719
|
+
allowlist: agentTarget.allowlist
|
|
2666
2720
|
};
|
|
2667
2721
|
}
|
|
2668
2722
|
function executionEnv(spec, metadata, opts) {
|
|
@@ -2674,6 +2728,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
2674
2728
|
Object.assign(env, accountEnv);
|
|
2675
2729
|
}
|
|
2676
2730
|
Object.assign(env, spec.env ?? {});
|
|
2731
|
+
Object.assign(env, allowlistEnv(spec.allowlist));
|
|
2677
2732
|
env.PATH = normalizeExecutionPath(env);
|
|
2678
2733
|
Object.assign(env, metadataEnv(metadata));
|
|
2679
2734
|
return env;
|
|
@@ -2712,6 +2767,9 @@ function remoteBootstrapLines(spec, metadata) {
|
|
|
2712
2767
|
continue;
|
|
2713
2768
|
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
2714
2769
|
}
|
|
2770
|
+
for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
|
|
2771
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
2772
|
+
}
|
|
2715
2773
|
return lines;
|
|
2716
2774
|
}
|
|
2717
2775
|
function remoteScript(spec, metadata) {
|
|
@@ -4060,6 +4118,7 @@ function loops(opts = {}) {
|
|
|
4060
4118
|
// src/lib/templates.ts
|
|
4061
4119
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
4062
4120
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
4121
|
+
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
4063
4122
|
var TEMPLATE_SUMMARIES = [
|
|
4064
4123
|
{
|
|
4065
4124
|
id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
@@ -4104,6 +4163,28 @@ var TEMPLATE_SUMMARIES = [
|
|
|
4104
4163
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
4105
4164
|
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
|
|
4106
4165
|
]
|
|
4166
|
+
},
|
|
4167
|
+
{
|
|
4168
|
+
id: BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID,
|
|
4169
|
+
name: "Bounded Agent Worker + Verifier",
|
|
4170
|
+
description: "Create a bounded recurring-agent workflow: one agent performs a narrow objective, then a fresh verifier audits the result with separate account/profile selection.",
|
|
4171
|
+
kind: "workflow",
|
|
4172
|
+
variables: [
|
|
4173
|
+
{ name: "objective", required: true, description: "Narrow goal-mode objective for the worker." },
|
|
4174
|
+
{ name: "prompt", description: "Optional extra worker prompt details." },
|
|
4175
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
4176
|
+
{ name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
|
|
4177
|
+
{ name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
|
|
4178
|
+
{ name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
|
|
4179
|
+
{ name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
|
|
4180
|
+
{ name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
|
|
4181
|
+
{ name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
|
|
4182
|
+
{ name: "model", description: "Provider model." },
|
|
4183
|
+
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
4184
|
+
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
4185
|
+
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
|
|
4186
|
+
{ name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
|
|
4187
|
+
]
|
|
4107
4188
|
}
|
|
4108
4189
|
];
|
|
4109
4190
|
function compactJson(value) {
|
|
@@ -4290,6 +4371,54 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
4290
4371
|
]
|
|
4291
4372
|
};
|
|
4292
4373
|
}
|
|
4374
|
+
function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
4375
|
+
if (!input.objective?.trim())
|
|
4376
|
+
throw new Error("objective is required");
|
|
4377
|
+
if (!input.projectPath?.trim())
|
|
4378
|
+
throw new Error("projectPath is required");
|
|
4379
|
+
const seed = `${input.projectPath}:${input.objective}`;
|
|
4380
|
+
const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
|
|
4381
|
+
const workerPrompt = [
|
|
4382
|
+
`/goal ${input.objective}`,
|
|
4383
|
+
"",
|
|
4384
|
+
"You are the worker step for a bounded OpenLoops agent workflow.",
|
|
4385
|
+
"Investigate first. Keep scope narrow, use local project/task systems as the source of truth when relevant, preserve unrelated changes, run focused validation, and record concise evidence.",
|
|
4386
|
+
"Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
|
|
4387
|
+
input.prompt ? "" : undefined,
|
|
4388
|
+
input.prompt
|
|
4389
|
+
].filter(Boolean).join(`
|
|
4390
|
+
`);
|
|
4391
|
+
const verifierPrompt = [
|
|
4392
|
+
`/goal Adversarially verify: ${input.objective}`,
|
|
4393
|
+
"",
|
|
4394
|
+
"You are the verifier step for a bounded OpenLoops agent workflow.",
|
|
4395
|
+
"Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
|
|
4396
|
+
"If valid, record verification evidence. If invalid, create precise follow-up tasks or comments and leave the original work open. Do not make broad unrelated changes."
|
|
4397
|
+
].join(`
|
|
4398
|
+
`);
|
|
4399
|
+
return {
|
|
4400
|
+
name: input.name ?? `bounded-agent-${stableIndex(seed, 65535).toString(16)}-worker-verifier`,
|
|
4401
|
+
description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
|
|
4402
|
+
version: 1,
|
|
4403
|
+
steps: [
|
|
4404
|
+
{
|
|
4405
|
+
id: "worker",
|
|
4406
|
+
name: "Worker",
|
|
4407
|
+
description: "Execute the bounded objective and record evidence.",
|
|
4408
|
+
target: agentTarget(input, workerPrompt, "worker", seed),
|
|
4409
|
+
timeoutMs
|
|
4410
|
+
},
|
|
4411
|
+
{
|
|
4412
|
+
id: "verifier",
|
|
4413
|
+
name: "Verifier",
|
|
4414
|
+
description: "Adversarially verify the bounded objective result.",
|
|
4415
|
+
dependsOn: ["worker"],
|
|
4416
|
+
target: agentTarget(input, verifierPrompt, "verifier", seed),
|
|
4417
|
+
timeoutMs: Math.min(timeoutMs, 30 * 60000)
|
|
4418
|
+
}
|
|
4419
|
+
]
|
|
4420
|
+
};
|
|
4421
|
+
}
|
|
4293
4422
|
function renderLoopTemplate(id, values) {
|
|
4294
4423
|
if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
4295
4424
|
return renderTodosTaskWorkerVerifierWorkflow({
|
|
@@ -4336,6 +4465,27 @@ function renderLoopTemplate(id, values) {
|
|
|
4336
4465
|
sandbox: values.sandbox
|
|
4337
4466
|
});
|
|
4338
4467
|
}
|
|
4468
|
+
if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
4469
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
4470
|
+
name: values.name,
|
|
4471
|
+
objective: values.objective ?? "",
|
|
4472
|
+
prompt: values.prompt,
|
|
4473
|
+
projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
|
|
4474
|
+
provider: values.provider,
|
|
4475
|
+
authProfile: values.authProfile,
|
|
4476
|
+
authProfilePool: listVar(values.authProfilePool),
|
|
4477
|
+
workerAuthProfile: values.workerAuthProfile,
|
|
4478
|
+
verifierAuthProfile: values.verifierAuthProfile,
|
|
4479
|
+
account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
|
|
4480
|
+
accountPool: accountPoolVar(values.accountPool, values.accountTool),
|
|
4481
|
+
model: values.model,
|
|
4482
|
+
variant: values.variant,
|
|
4483
|
+
agent: values.agent,
|
|
4484
|
+
permissionMode: values.permissionMode,
|
|
4485
|
+
sandbox: values.sandbox,
|
|
4486
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
|
|
4487
|
+
});
|
|
4488
|
+
}
|
|
4339
4489
|
throw new Error(`unknown template: ${id}`);
|
|
4340
4490
|
}
|
|
4341
4491
|
function listVar(value) {
|
|
@@ -4582,6 +4732,428 @@ function runDoctor(store) {
|
|
|
4582
4732
|
checks
|
|
4583
4733
|
};
|
|
4584
4734
|
}
|
|
4735
|
+
// src/lib/health.ts
|
|
4736
|
+
import { createHash } from "crypto";
|
|
4737
|
+
var EVIDENCE_CHARS = 2000;
|
|
4738
|
+
var CLASSIFICATIONS = [
|
|
4739
|
+
"rate_limit",
|
|
4740
|
+
"auth",
|
|
4741
|
+
"model_not_found",
|
|
4742
|
+
"context_length",
|
|
4743
|
+
"schema_response_format",
|
|
4744
|
+
"node_init",
|
|
4745
|
+
"timeout",
|
|
4746
|
+
"sigsegv",
|
|
4747
|
+
"skipped_previous_active",
|
|
4748
|
+
"unknown"
|
|
4749
|
+
];
|
|
4750
|
+
function bounded(value, limit = EVIDENCE_CHARS) {
|
|
4751
|
+
if (!value)
|
|
4752
|
+
return;
|
|
4753
|
+
if (value.length <= limit)
|
|
4754
|
+
return value;
|
|
4755
|
+
return `${value.slice(0, limit)}
|
|
4756
|
+
[truncated ${value.length - limit} chars]`;
|
|
4757
|
+
}
|
|
4758
|
+
function searchableText(run) {
|
|
4759
|
+
return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
|
|
4760
|
+
`).toLowerCase();
|
|
4761
|
+
}
|
|
4762
|
+
function stableFingerprint(parts) {
|
|
4763
|
+
return createHash("sha256").update(parts.join(`
|
|
4764
|
+
`)).digest("hex").slice(0, 16);
|
|
4765
|
+
}
|
|
4766
|
+
function healthRun(run) {
|
|
4767
|
+
return {
|
|
4768
|
+
...run,
|
|
4769
|
+
error: bounded(run.error),
|
|
4770
|
+
stdout: bounded(run.stdout),
|
|
4771
|
+
stderr: bounded(run.stderr)
|
|
4772
|
+
};
|
|
4773
|
+
}
|
|
4774
|
+
function classifyRunFailure(run) {
|
|
4775
|
+
if (run.status === "succeeded" || run.status === "running")
|
|
4776
|
+
return;
|
|
4777
|
+
const text = searchableText(run);
|
|
4778
|
+
let classification = "unknown";
|
|
4779
|
+
if (run.status === "timed_out")
|
|
4780
|
+
classification = "timeout";
|
|
4781
|
+
else if (run.status === "skipped" && /previous run still active/.test(text))
|
|
4782
|
+
classification = "skipped_previous_active";
|
|
4783
|
+
else if (/rate limit|too many requests|429\b|quota exceeded/.test(text))
|
|
4784
|
+
classification = "rate_limit";
|
|
4785
|
+
else if (/unauthorized|authentication|auth\b|api key|invalid token|permission denied|401\b|403\b/.test(text))
|
|
4786
|
+
classification = "auth";
|
|
4787
|
+
else if (/model .*not found|model_not_found|unknown model|invalid model|404.*model/.test(text))
|
|
4788
|
+
classification = "model_not_found";
|
|
4789
|
+
else if (/context length|context_length|context window|maximum context|token limit|too many tokens/.test(text))
|
|
4790
|
+
classification = "context_length";
|
|
4791
|
+
else if (/response_format|json schema|schema validation|invalid schema|structured output/.test(text))
|
|
4792
|
+
classification = "schema_response_format";
|
|
4793
|
+
else if (/cannot find module|module not found|node:internal|bun: command not found|node: command not found|npm err!|err_module_not_found/.test(text))
|
|
4794
|
+
classification = "node_init";
|
|
4795
|
+
else if (/sigsegv|segmentation fault|signal 11/.test(text))
|
|
4796
|
+
classification = "sigsegv";
|
|
4797
|
+
return {
|
|
4798
|
+
classification,
|
|
4799
|
+
fingerprint: stableFingerprint([
|
|
4800
|
+
run.loopId,
|
|
4801
|
+
run.loopName,
|
|
4802
|
+
run.status,
|
|
4803
|
+
classification,
|
|
4804
|
+
String(run.exitCode ?? ""),
|
|
4805
|
+
(run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
|
|
4806
|
+
]),
|
|
4807
|
+
evidence: {
|
|
4808
|
+
error: bounded(run.error),
|
|
4809
|
+
stdout: bounded(run.stdout),
|
|
4810
|
+
stderr: bounded(run.stderr),
|
|
4811
|
+
exitCode: run.exitCode
|
|
4812
|
+
}
|
|
4813
|
+
};
|
|
4814
|
+
}
|
|
4815
|
+
function targetRoute(loop) {
|
|
4816
|
+
if (loop.target.type === "agent") {
|
|
4817
|
+
return {
|
|
4818
|
+
source: "openloops",
|
|
4819
|
+
kind: "loop_expectation",
|
|
4820
|
+
loopId: loop.id,
|
|
4821
|
+
loopName: loop.name,
|
|
4822
|
+
cwd: loop.target.cwd,
|
|
4823
|
+
provider: loop.target.provider
|
|
4824
|
+
};
|
|
4825
|
+
}
|
|
4826
|
+
if (loop.target.type === "command") {
|
|
4827
|
+
return {
|
|
4828
|
+
source: "openloops",
|
|
4829
|
+
kind: "loop_expectation",
|
|
4830
|
+
loopId: loop.id,
|
|
4831
|
+
loopName: loop.name,
|
|
4832
|
+
cwd: loop.target.cwd
|
|
4833
|
+
};
|
|
4834
|
+
}
|
|
4835
|
+
return {
|
|
4836
|
+
source: "openloops",
|
|
4837
|
+
kind: "loop_expectation",
|
|
4838
|
+
loopId: loop.id,
|
|
4839
|
+
loopName: loop.name
|
|
4840
|
+
};
|
|
4841
|
+
}
|
|
4842
|
+
function recommendedTask(loop, run, failure, route) {
|
|
4843
|
+
const title = `BUG: open-loops loop failure - ${loop.name}`;
|
|
4844
|
+
const description = [
|
|
4845
|
+
`OpenLoops expectation failed for loop ${loop.name} (${loop.id}).`,
|
|
4846
|
+
`Run: ${run.id}`,
|
|
4847
|
+
`Status: ${run.status}`,
|
|
4848
|
+
`Classification: ${failure.classification}`,
|
|
4849
|
+
`Fingerprint: ${failure.fingerprint}`,
|
|
4850
|
+
route.cwd ? `Route cwd: ${route.cwd}` : undefined,
|
|
4851
|
+
route.provider ? `Provider: ${route.provider}` : undefined,
|
|
4852
|
+
failure.evidence.error ? `Error:
|
|
4853
|
+
${failure.evidence.error}` : undefined,
|
|
4854
|
+
failure.evidence.stderr ? `Stderr:
|
|
4855
|
+
${failure.evidence.stderr}` : undefined
|
|
4856
|
+
].filter(Boolean).join(`
|
|
4857
|
+
|
|
4858
|
+
`);
|
|
4859
|
+
const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
|
|
4860
|
+
const tags = ["bug", "openloops", "loop-health", failure.classification];
|
|
4861
|
+
const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
|
|
4862
|
+
return {
|
|
4863
|
+
title,
|
|
4864
|
+
description,
|
|
4865
|
+
priority,
|
|
4866
|
+
tags,
|
|
4867
|
+
dedupeKey,
|
|
4868
|
+
search: { query: dedupeKey },
|
|
4869
|
+
compatibilityFallback: {
|
|
4870
|
+
search: ["todos", "search", dedupeKey, "--json"],
|
|
4871
|
+
add: ["todos", "add", title, "--description", description, "--tag", tags.join(","), "--priority", priority],
|
|
4872
|
+
comment: ["todos", "comment", "<task-id>", description]
|
|
4873
|
+
},
|
|
4874
|
+
futureNativeUpsert: {
|
|
4875
|
+
command: "todos upsert",
|
|
4876
|
+
fields: {
|
|
4877
|
+
title,
|
|
4878
|
+
description,
|
|
4879
|
+
priority,
|
|
4880
|
+
tags,
|
|
4881
|
+
dedupeKey,
|
|
4882
|
+
routeSource: route.source,
|
|
4883
|
+
routeKind: route.kind,
|
|
4884
|
+
routeLoopId: route.loopId,
|
|
4885
|
+
routeLoopName: route.loopName
|
|
4886
|
+
}
|
|
4887
|
+
}
|
|
4888
|
+
};
|
|
4889
|
+
}
|
|
4890
|
+
function expectationForLoop(store, loop) {
|
|
4891
|
+
const latestRun = store.listRuns({ loopId: loop.id, limit: 1 })[0];
|
|
4892
|
+
const route = targetRoute(loop);
|
|
4893
|
+
if (!latestRun) {
|
|
4894
|
+
return {
|
|
4895
|
+
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
4896
|
+
ok: true,
|
|
4897
|
+
check: { id: "latest-run-succeeded", status: "warn", message: "loop has no recorded runs yet" },
|
|
4898
|
+
route
|
|
4899
|
+
};
|
|
4900
|
+
}
|
|
4901
|
+
if (latestRun.status === "succeeded") {
|
|
4902
|
+
return {
|
|
4903
|
+
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
4904
|
+
ok: true,
|
|
4905
|
+
check: { id: "latest-run-succeeded", status: "pass", message: "latest run succeeded" },
|
|
4906
|
+
latestRun: healthRun(latestRun),
|
|
4907
|
+
route
|
|
4908
|
+
};
|
|
4909
|
+
}
|
|
4910
|
+
const failure = classifyRunFailure(latestRun);
|
|
4911
|
+
return {
|
|
4912
|
+
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
4913
|
+
ok: false,
|
|
4914
|
+
check: { id: "latest-run-succeeded", status: "fail", message: `latest run is ${latestRun.status}` },
|
|
4915
|
+
latestRun: healthRun(latestRun),
|
|
4916
|
+
failure,
|
|
4917
|
+
route,
|
|
4918
|
+
recommendedTask: failure ? recommendedTask(loop, latestRun, failure, route) : undefined
|
|
4919
|
+
};
|
|
4920
|
+
}
|
|
4921
|
+
function buildHealthReport(store, opts = {}) {
|
|
4922
|
+
const loops2 = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
|
|
4923
|
+
const expectations = loops2.map((loop) => expectationForLoop(store, loop));
|
|
4924
|
+
const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
|
|
4925
|
+
for (const expectation of expectations) {
|
|
4926
|
+
if (expectation.failure)
|
|
4927
|
+
classifications[expectation.failure.classification] += 1;
|
|
4928
|
+
}
|
|
4929
|
+
const unhealthy = expectations.filter((expectation) => !expectation.ok).length;
|
|
4930
|
+
const warnings = expectations.filter((expectation) => expectation.check.status === "warn").length;
|
|
4931
|
+
return {
|
|
4932
|
+
ok: unhealthy === 0,
|
|
4933
|
+
generatedAt: new Date().toISOString(),
|
|
4934
|
+
summary: {
|
|
4935
|
+
loops: expectations.length,
|
|
4936
|
+
healthy: expectations.length - unhealthy,
|
|
4937
|
+
unhealthy,
|
|
4938
|
+
warnings
|
|
4939
|
+
},
|
|
4940
|
+
classifications,
|
|
4941
|
+
expectations
|
|
4942
|
+
};
|
|
4943
|
+
}
|
|
4944
|
+
// src/lib/hygiene.ts
|
|
4945
|
+
import { basename } from "path";
|
|
4946
|
+
var PROVIDER_TOKENS = new Set([
|
|
4947
|
+
"codewith",
|
|
4948
|
+
"claude",
|
|
4949
|
+
"command",
|
|
4950
|
+
"tmux",
|
|
4951
|
+
"codex",
|
|
4952
|
+
"cursor",
|
|
4953
|
+
"opencode",
|
|
4954
|
+
"aicopilot",
|
|
4955
|
+
"agent"
|
|
4956
|
+
]);
|
|
4957
|
+
var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
|
|
4958
|
+
function slugify(value) {
|
|
4959
|
+
return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
4960
|
+
}
|
|
4961
|
+
function repoSlugFromCwd(cwd) {
|
|
4962
|
+
if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
|
|
4963
|
+
return "";
|
|
4964
|
+
if (cwd.includes("/.hasna/loops/"))
|
|
4965
|
+
return "";
|
|
4966
|
+
return slugify(basename(cwd));
|
|
4967
|
+
}
|
|
4968
|
+
function scopeForLoop(loop) {
|
|
4969
|
+
const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
|
|
4970
|
+
const repoSlug = repoSlugFromCwd(cwd);
|
|
4971
|
+
if (repoSlug)
|
|
4972
|
+
return { scope: "repo", prefix: `repo-${repoSlug}`, scopeSlug: repoSlug };
|
|
4973
|
+
return { scope: "machine", prefix: "machine", scopeSlug: "machine" };
|
|
4974
|
+
}
|
|
4975
|
+
function taskSlug(loop, scope) {
|
|
4976
|
+
const oldName = loop.name;
|
|
4977
|
+
let nameForParsing = oldName;
|
|
4978
|
+
if (!oldName.includes(":")) {
|
|
4979
|
+
const slug = slugify(oldName);
|
|
4980
|
+
if (scope.scope === "machine" && slug.startsWith("machine-"))
|
|
4981
|
+
nameForParsing = slug.slice("machine-".length);
|
|
4982
|
+
else if (scope.scope === "repo" && slug.startsWith(`repo-${scope.scopeSlug}-`)) {
|
|
4983
|
+
nameForParsing = slug.slice(`repo-${scope.scopeSlug}-`.length);
|
|
4984
|
+
} else
|
|
4985
|
+
nameForParsing = slug;
|
|
4986
|
+
}
|
|
4987
|
+
const parts = [];
|
|
4988
|
+
for (const rawPart of oldName.includes(":") ? oldName.split(":") : [nameForParsing]) {
|
|
4989
|
+
const part = slugify(rawPart);
|
|
4990
|
+
if (!part)
|
|
4991
|
+
continue;
|
|
4992
|
+
if (PROVIDER_TOKENS.has(part) || /^account\d+$/.test(part))
|
|
4993
|
+
continue;
|
|
4994
|
+
if (scope.scope === "repo" && REPO_GENERIC_TOKENS.has(part))
|
|
4995
|
+
continue;
|
|
4996
|
+
let normalized = part;
|
|
4997
|
+
if (scope.scope === "repo" && normalized === scope.scopeSlug)
|
|
4998
|
+
continue;
|
|
4999
|
+
if (scope.scope === "repo" && normalized.startsWith(`${scope.scopeSlug}-`)) {
|
|
5000
|
+
normalized = normalized.slice(scope.scopeSlug.length + 1);
|
|
5001
|
+
}
|
|
5002
|
+
if (normalized)
|
|
5003
|
+
parts.push(normalized);
|
|
5004
|
+
}
|
|
5005
|
+
const deduped = [];
|
|
5006
|
+
for (const token of parts.join("-").split("-").filter(Boolean)) {
|
|
5007
|
+
if (deduped[deduped.length - 1] !== token)
|
|
5008
|
+
deduped.push(token);
|
|
5009
|
+
}
|
|
5010
|
+
return deduped.join("-") || "loop";
|
|
5011
|
+
}
|
|
5012
|
+
function canonicalName(loop) {
|
|
5013
|
+
const scope = scopeForLoop(loop);
|
|
5014
|
+
let name = `${scope.prefix}-${taskSlug(loop, scope)}`.replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
5015
|
+
if (name.length > 120)
|
|
5016
|
+
name = `${name.slice(0, 111).replace(/-+$/g, "")}-${loop.id.slice(0, 8)}`;
|
|
5017
|
+
return {
|
|
5018
|
+
id: loop.id,
|
|
5019
|
+
status: loop.status,
|
|
5020
|
+
scope: scope.scope,
|
|
5021
|
+
scopeSlug: scope.scopeSlug,
|
|
5022
|
+
newName: name
|
|
5023
|
+
};
|
|
5024
|
+
}
|
|
5025
|
+
function ensureUnique(changes) {
|
|
5026
|
+
const used = new Set;
|
|
5027
|
+
for (const change of changes) {
|
|
5028
|
+
let candidate = change.newName;
|
|
5029
|
+
if (!used.has(candidate)) {
|
|
5030
|
+
used.add(candidate);
|
|
5031
|
+
change.newName = candidate;
|
|
5032
|
+
change.changed = change.oldName !== candidate;
|
|
5033
|
+
continue;
|
|
5034
|
+
}
|
|
5035
|
+
const base = candidate.slice(0, 111).replace(/-+$/g, "");
|
|
5036
|
+
candidate = `${base}-${change.id.slice(0, 8)}`;
|
|
5037
|
+
let suffix = 2;
|
|
5038
|
+
while (used.has(candidate)) {
|
|
5039
|
+
const extra = `-${change.id.slice(0, 6)}-${suffix++}`;
|
|
5040
|
+
candidate = `${base.slice(0, 120 - extra.length)}${extra}`;
|
|
5041
|
+
}
|
|
5042
|
+
used.add(candidate);
|
|
5043
|
+
change.newName = candidate;
|
|
5044
|
+
change.changed = change.oldName !== candidate;
|
|
5045
|
+
}
|
|
5046
|
+
}
|
|
5047
|
+
function managedLoops(store, opts) {
|
|
5048
|
+
const loops2 = store.listLoops({ includeArchived: Boolean(opts.includeInactive), limit: opts.limit ?? 1000 });
|
|
5049
|
+
if (opts.includeInactive)
|
|
5050
|
+
return loops2;
|
|
5051
|
+
if (opts.includeStopped)
|
|
5052
|
+
return loops2.filter((loop) => loop.status !== "expired");
|
|
5053
|
+
return loops2.filter((loop) => loop.status === "active" || loop.status === "paused");
|
|
5054
|
+
}
|
|
5055
|
+
function buildNameHygieneReport(store, opts = {}) {
|
|
5056
|
+
const changes = managedLoops(store, opts).map((loop) => {
|
|
5057
|
+
const canonical = canonicalName(loop);
|
|
5058
|
+
return {
|
|
5059
|
+
...canonical,
|
|
5060
|
+
oldName: loop.name,
|
|
5061
|
+
changed: loop.name !== canonical.newName
|
|
5062
|
+
};
|
|
5063
|
+
});
|
|
5064
|
+
ensureUnique(changes);
|
|
5065
|
+
const changed = changes.filter((change) => change.changed);
|
|
5066
|
+
if (opts.apply) {
|
|
5067
|
+
for (const change of changed)
|
|
5068
|
+
store.renameLoop(change.id, change.newName);
|
|
5069
|
+
}
|
|
5070
|
+
return {
|
|
5071
|
+
ok: changed.length === 0,
|
|
5072
|
+
generatedAt: new Date().toISOString(),
|
|
5073
|
+
applied: Boolean(opts.apply),
|
|
5074
|
+
checked: changes.length,
|
|
5075
|
+
changed: changed.length,
|
|
5076
|
+
changes
|
|
5077
|
+
};
|
|
5078
|
+
}
|
|
5079
|
+
function baseName(name) {
|
|
5080
|
+
return name.replace(/-(bounded|compact|native)?-?low(?:-\d+m)?$/g, "").replace(/-\d+[mhd]$/g, "").replace(/-(bounded|compact)$/g, "");
|
|
5081
|
+
}
|
|
5082
|
+
function scheduleKey(schedule) {
|
|
5083
|
+
if (schedule.type === "cron")
|
|
5084
|
+
return `cron:${schedule.expression}`;
|
|
5085
|
+
if (schedule.type === "interval")
|
|
5086
|
+
return `interval:${schedule.everyMs}`;
|
|
5087
|
+
if (schedule.type === "once")
|
|
5088
|
+
return `once:${schedule.at}`;
|
|
5089
|
+
return `dynamic:${schedule.minIntervalMs ?? ""}`;
|
|
5090
|
+
}
|
|
5091
|
+
function targetCwd(loop) {
|
|
5092
|
+
return loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd ?? "" : "";
|
|
5093
|
+
}
|
|
5094
|
+
function buildDuplicateOverlapReport(store, opts = {}) {
|
|
5095
|
+
const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
|
|
5096
|
+
const groups = new Map;
|
|
5097
|
+
for (const loop of loops2) {
|
|
5098
|
+
const base = baseName(loop.name);
|
|
5099
|
+
const cwd = targetCwd(loop) || undefined;
|
|
5100
|
+
const schedule = scheduleKey(loop.schedule);
|
|
5101
|
+
const key = `${base}|${cwd ?? ""}|${schedule}`;
|
|
5102
|
+
const existing = groups.get(key) ?? { baseName: base, cwd, schedule, loops: [] };
|
|
5103
|
+
existing.loops.push(loop);
|
|
5104
|
+
groups.set(key, existing);
|
|
5105
|
+
}
|
|
5106
|
+
const duplicateGroups = [...groups.entries()].filter(([, group]) => group.loops.length > 1).map(([key, group]) => ({
|
|
5107
|
+
key,
|
|
5108
|
+
baseName: group.baseName,
|
|
5109
|
+
cwd: group.cwd,
|
|
5110
|
+
schedule: group.schedule,
|
|
5111
|
+
loops: group.loops.map((loop) => ({
|
|
5112
|
+
id: loop.id,
|
|
5113
|
+
name: loop.name,
|
|
5114
|
+
status: loop.status,
|
|
5115
|
+
nextRunAt: loop.nextRunAt
|
|
5116
|
+
}))
|
|
5117
|
+
}));
|
|
5118
|
+
return {
|
|
5119
|
+
ok: duplicateGroups.length === 0,
|
|
5120
|
+
generatedAt: new Date().toISOString(),
|
|
5121
|
+
checked: loops2.length,
|
|
5122
|
+
groups: duplicateGroups
|
|
5123
|
+
};
|
|
5124
|
+
}
|
|
5125
|
+
function commandText(loop) {
|
|
5126
|
+
if (loop.target.type !== "command")
|
|
5127
|
+
return "";
|
|
5128
|
+
return [loop.target.command, ...loop.target.args ?? []].join(" ");
|
|
5129
|
+
}
|
|
5130
|
+
function buildScriptInventoryReport(store, opts = {}) {
|
|
5131
|
+
const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
|
|
5132
|
+
const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
|
|
5133
|
+
const scriptBacked = loops2.map((loop) => {
|
|
5134
|
+
const text = commandText(loop);
|
|
5135
|
+
if (!text)
|
|
5136
|
+
return;
|
|
5137
|
+
const matches = [scriptsDir, "/.hasna/loops/scripts/"].filter((needle) => text.includes(needle));
|
|
5138
|
+
if (!matches.length)
|
|
5139
|
+
return;
|
|
5140
|
+
return {
|
|
5141
|
+
id: loop.id,
|
|
5142
|
+
name: loop.name,
|
|
5143
|
+
status: loop.status,
|
|
5144
|
+
cwd: targetCwd(loop) || undefined,
|
|
5145
|
+
command: text.length > 500 ? `${text.slice(0, 500)}...` : text,
|
|
5146
|
+
scriptMatches: [...new Set(matches)]
|
|
5147
|
+
};
|
|
5148
|
+
}).filter((value) => Boolean(value));
|
|
5149
|
+
return {
|
|
5150
|
+
ok: scriptBacked.length === 0,
|
|
5151
|
+
generatedAt: new Date().toISOString(),
|
|
5152
|
+
checked: loops2.length,
|
|
5153
|
+
scriptBacked: scriptBacked.length,
|
|
5154
|
+
loops: scriptBacked
|
|
5155
|
+
};
|
|
5156
|
+
}
|
|
4585
5157
|
export {
|
|
4586
5158
|
workflowExecutionOrder,
|
|
4587
5159
|
workflowBodyFromJson,
|
|
@@ -4594,6 +5166,7 @@ export {
|
|
|
4594
5166
|
renderTodosTaskWorkerVerifierWorkflow,
|
|
4595
5167
|
renderLoopTemplate,
|
|
4596
5168
|
renderEventWorkerVerifierWorkflow,
|
|
5169
|
+
renderBoundedAgentWorkerVerifierWorkflow,
|
|
4597
5170
|
refreshLoopMachine,
|
|
4598
5171
|
readyNodeKeys,
|
|
4599
5172
|
preflightWorkflow,
|
|
@@ -4607,13 +5180,20 @@ export {
|
|
|
4607
5180
|
isTerminal as isGoalTerminal,
|
|
4608
5181
|
initialNextRun,
|
|
4609
5182
|
getLoopTemplate,
|
|
5183
|
+
expectationForLoop,
|
|
4610
5184
|
executeWorkflow,
|
|
4611
5185
|
executeTarget,
|
|
4612
5186
|
executeLoopTarget,
|
|
4613
5187
|
executeLoop,
|
|
4614
5188
|
computeNextAfter,
|
|
5189
|
+
classifyRunFailure,
|
|
5190
|
+
buildScriptInventoryReport,
|
|
5191
|
+
buildNameHygieneReport,
|
|
5192
|
+
buildHealthReport,
|
|
5193
|
+
buildDuplicateOverlapReport,
|
|
4615
5194
|
TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
4616
5195
|
Store,
|
|
4617
5196
|
LoopsClient,
|
|
4618
|
-
EVENT_WORKER_VERIFIER_TEMPLATE_ID
|
|
5197
|
+
EVENT_WORKER_VERIFIER_TEMPLATE_ID,
|
|
5198
|
+
BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID
|
|
4619
5199
|
};
|