@hasna/loops 0.3.16 → 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 +491 -2
- package/dist/daemon/index.js +25 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +335 -1
- package/dist/lib/hygiene.d.ts +62 -0
- package/dist/lib/store.d.ts +1 -0
- package/dist/lib/store.js +24 -0
- package/dist/lib/templates.d.ts +23 -0
- package/dist/sdk/index.js +24 -0
- package/docs/USAGE.md +39 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -210,7 +210,8 @@ Use `shell: true` only when you intentionally want shell parsing:
|
|
|
210
210
|
Built-in templates turn common orchestration flows into reusable workflow JSON.
|
|
211
211
|
`todos-task-worker-verifier` performs one todos task and then verifies it.
|
|
212
212
|
`event-worker-verifier` handles any Hasna event envelope and then verifies the
|
|
213
|
-
handling.
|
|
213
|
+
handling. `bounded-agent-worker-verifier` is for recurring bounded agent work:
|
|
214
|
+
one worker runs a narrow objective, then a fresh verifier audits the result.
|
|
214
215
|
|
|
215
216
|
```bash
|
|
216
217
|
loops templates list
|
|
@@ -230,6 +231,12 @@ loops templates render event-worker-verifier \
|
|
|
230
231
|
--var eventSource=knowledge \
|
|
231
232
|
--var eventJson='{"id":"<event-id>"}' \
|
|
232
233
|
--var projectPath=/path/to/repo
|
|
234
|
+
loops templates render bounded-agent-worker-verifier \
|
|
235
|
+
--var objective="Check docs drift and queue tasks for gaps" \
|
|
236
|
+
--var projectPath=/path/to/repo \
|
|
237
|
+
--var provider=codewith \
|
|
238
|
+
--var authProfilePool=account004,account005 \
|
|
239
|
+
--var sandbox=danger-full-access
|
|
233
240
|
```
|
|
234
241
|
|
|
235
242
|
For event-driven task automation, `loops events handle todos-task` reads a
|
|
@@ -293,6 +300,28 @@ loops run-now <id-or-name>
|
|
|
293
300
|
|
|
294
301
|
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.
|
|
295
302
|
|
|
303
|
+
## Health And Hygiene
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
loops health --json
|
|
307
|
+
loops expectations <loop-id-or-name> --json
|
|
308
|
+
loops health route-tasks --project ~/.hasna/loops --task-list loop-error-self-heal --max-actions 5
|
|
309
|
+
loops hygiene names --json
|
|
310
|
+
loops hygiene duplicates --json
|
|
311
|
+
loops hygiene scripts --json
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`health` and `expectations` classify latest-run failures with stable
|
|
315
|
+
fingerprints and bounded evidence. `health route-tasks` is the explicit
|
|
316
|
+
mutating path: it upserts deduped Todos tasks for failed expectations and marks
|
|
317
|
+
them with `no_tmux_dispatch=true` metadata. Use `--dry-run --json` before
|
|
318
|
+
turning it into a production loop.
|
|
319
|
+
|
|
320
|
+
`hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
|
|
321
|
+
renames only with `--apply`. `hygiene duplicates` groups loops with the same
|
|
322
|
+
normalized name, cwd, and schedule. `hygiene scripts` inventories loops whose
|
|
323
|
+
command still references `~/.hasna/loops/scripts`.
|
|
324
|
+
|
|
296
325
|
Archive loops when retiring old automation but preserving history:
|
|
297
326
|
|
|
298
327
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -1052,6 +1052,30 @@ class Store {
|
|
|
1052
1052
|
throw new Error(`loop not found after update: ${id}`);
|
|
1053
1053
|
return after;
|
|
1054
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
|
+
}
|
|
1055
1079
|
archiveLoop(idOrName) {
|
|
1056
1080
|
const loop = this.requireLoop(idOrName);
|
|
1057
1081
|
if (loop.archivedAt)
|
|
@@ -2173,6 +2197,7 @@ class Store {
|
|
|
2173
2197
|
// src/cli/index.ts
|
|
2174
2198
|
import { createHash as createHash2 } from "crypto";
|
|
2175
2199
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
2200
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
2176
2201
|
import { Command } from "commander";
|
|
2177
2202
|
|
|
2178
2203
|
// src/lib/format.ts
|
|
@@ -4821,10 +4846,224 @@ function buildHealthReport(store, opts = {}) {
|
|
|
4821
4846
|
expectations
|
|
4822
4847
|
};
|
|
4823
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
|
+
}
|
|
4824
5063
|
// package.json
|
|
4825
5064
|
var package_default = {
|
|
4826
5065
|
name: "@hasna/loops",
|
|
4827
|
-
version: "0.3.
|
|
5066
|
+
version: "0.3.17",
|
|
4828
5067
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4829
5068
|
type: "module",
|
|
4830
5069
|
main: "dist/index.js",
|
|
@@ -4915,6 +5154,7 @@ function packageVersion() {
|
|
|
4915
5154
|
// src/lib/templates.ts
|
|
4916
5155
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
4917
5156
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
5157
|
+
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
4918
5158
|
var TEMPLATE_SUMMARIES = [
|
|
4919
5159
|
{
|
|
4920
5160
|
id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
@@ -4959,6 +5199,28 @@ var TEMPLATE_SUMMARIES = [
|
|
|
4959
5199
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
4960
5200
|
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
|
|
4961
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
|
+
]
|
|
4962
5224
|
}
|
|
4963
5225
|
];
|
|
4964
5226
|
function compactJson(value) {
|
|
@@ -5145,6 +5407,54 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5145
5407
|
]
|
|
5146
5408
|
};
|
|
5147
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
|
+
}
|
|
5148
5458
|
function renderLoopTemplate(id, values) {
|
|
5149
5459
|
if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
5150
5460
|
return renderTodosTaskWorkerVerifierWorkflow({
|
|
@@ -5191,6 +5501,27 @@ function renderLoopTemplate(id, values) {
|
|
|
5191
5501
|
sandbox: values.sandbox
|
|
5192
5502
|
});
|
|
5193
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
|
+
}
|
|
5194
5525
|
throw new Error(`unknown template: ${id}`);
|
|
5195
5526
|
}
|
|
5196
5527
|
function listVar(value) {
|
|
@@ -5355,6 +5686,36 @@ function collectValues(value, previous = []) {
|
|
|
5355
5686
|
previous.push(value);
|
|
5356
5687
|
return previous;
|
|
5357
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
|
+
}
|
|
5358
5719
|
function eventData(event) {
|
|
5359
5720
|
const data = event.data;
|
|
5360
5721
|
if (data && typeof data === "object" && !Array.isArray(data))
|
|
@@ -6089,7 +6450,7 @@ program.command("expectations [idOrName]").description("evaluate deterministic l
|
|
|
6089
6450
|
store.close();
|
|
6090
6451
|
}
|
|
6091
6452
|
});
|
|
6092
|
-
program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
|
|
6453
|
+
var health = program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
|
|
6093
6454
|
const store = new Store;
|
|
6094
6455
|
try {
|
|
6095
6456
|
const report = buildHealthReport(store);
|
|
@@ -6107,6 +6468,134 @@ program.command("health").description("summarize loop health and latest-run expe
|
|
|
6107
6468
|
store.close();
|
|
6108
6469
|
}
|
|
6109
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
|
+
});
|
|
6110
6599
|
program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
|
|
6111
6600
|
program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
|
|
6112
6601
|
program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
|
package/dist/daemon/index.js
CHANGED
|
@@ -1052,6 +1052,30 @@ class Store {
|
|
|
1052
1052
|
throw new Error(`loop not found after update: ${id}`);
|
|
1053
1053
|
return after;
|
|
1054
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
|
+
}
|
|
1055
1079
|
archiveLoop(idOrName) {
|
|
1056
1080
|
const loop = this.requireLoop(idOrName);
|
|
1057
1081
|
if (loop.archivedAt)
|
|
@@ -4395,7 +4419,7 @@ function enableStartup(result) {
|
|
|
4395
4419
|
// package.json
|
|
4396
4420
|
var package_default = {
|
|
4397
4421
|
name: "@hasna/loops",
|
|
4398
|
-
version: "0.3.
|
|
4422
|
+
version: "0.3.17",
|
|
4399
4423
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4400
4424
|
type: "module",
|
|
4401
4425
|
main: "dist/index.js",
|
package/dist/index.d.ts
CHANGED
|
@@ -8,9 +8,10 @@ export { listOpenMachines, refreshLoopMachine, resolveLoopMachine } from "./lib/
|
|
|
8
8
|
export { tick } from "./lib/scheduler.js";
|
|
9
9
|
export { executeWorkflow, executeLoopTarget, preflightWorkflow } from "./lib/workflow-runner.js";
|
|
10
10
|
export { workflowExecutionOrder, workflowBodyFromJson } from "./lib/workflow-spec.js";
|
|
11
|
-
export { EVENT_WORKER_VERIFIER_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, getLoopTemplate, listLoopTemplates, renderEventWorkerVerifierWorkflow, renderLoopTemplate, renderTodosTaskWorkerVerifierWorkflow, } from "./lib/templates.js";
|
|
11
|
+
export { BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID, EVENT_WORKER_VERIFIER_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, getLoopTemplate, listLoopTemplates, renderBoundedAgentWorkerVerifierWorkflow, renderEventWorkerVerifierWorkflow, renderLoopTemplate, renderTodosTaskWorkerVerifierWorkflow, } from "./lib/templates.js";
|
|
12
12
|
export { runDoctor } from "./lib/doctor.js";
|
|
13
13
|
export { buildHealthReport, classifyRunFailure, expectationForLoop } from "./lib/health.js";
|
|
14
|
+
export { buildDuplicateOverlapReport, buildNameHygieneReport, buildScriptInventoryReport } from "./lib/hygiene.js";
|
|
14
15
|
export { runGoal } from "./lib/goal/runner.js";
|
|
15
16
|
export { resolveGoalModel } from "./lib/goal/model-factory.js";
|
|
16
17
|
export { isTerminal as isGoalTerminal, readyNodeKeys, rollupSummary } from "./lib/goal/status.js";
|
package/dist/index.js
CHANGED
|
@@ -1050,6 +1050,30 @@ class Store {
|
|
|
1050
1050
|
throw new Error(`loop not found after update: ${id}`);
|
|
1051
1051
|
return after;
|
|
1052
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
|
+
}
|
|
1053
1077
|
archiveLoop(idOrName) {
|
|
1054
1078
|
const loop = this.requireLoop(idOrName);
|
|
1055
1079
|
if (loop.archivedAt)
|
|
@@ -4094,6 +4118,7 @@ function loops(opts = {}) {
|
|
|
4094
4118
|
// src/lib/templates.ts
|
|
4095
4119
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
4096
4120
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
4121
|
+
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
4097
4122
|
var TEMPLATE_SUMMARIES = [
|
|
4098
4123
|
{
|
|
4099
4124
|
id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
@@ -4138,6 +4163,28 @@ var TEMPLATE_SUMMARIES = [
|
|
|
4138
4163
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
4139
4164
|
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
|
|
4140
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
|
+
]
|
|
4141
4188
|
}
|
|
4142
4189
|
];
|
|
4143
4190
|
function compactJson(value) {
|
|
@@ -4324,6 +4371,54 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
4324
4371
|
]
|
|
4325
4372
|
};
|
|
4326
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
|
+
}
|
|
4327
4422
|
function renderLoopTemplate(id, values) {
|
|
4328
4423
|
if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
4329
4424
|
return renderTodosTaskWorkerVerifierWorkflow({
|
|
@@ -4370,6 +4465,27 @@ function renderLoopTemplate(id, values) {
|
|
|
4370
4465
|
sandbox: values.sandbox
|
|
4371
4466
|
});
|
|
4372
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
|
+
}
|
|
4373
4489
|
throw new Error(`unknown template: ${id}`);
|
|
4374
4490
|
}
|
|
4375
4491
|
function listVar(value) {
|
|
@@ -4825,6 +4941,219 @@ function buildHealthReport(store, opts = {}) {
|
|
|
4825
4941
|
expectations
|
|
4826
4942
|
};
|
|
4827
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
|
+
}
|
|
4828
5157
|
export {
|
|
4829
5158
|
workflowExecutionOrder,
|
|
4830
5159
|
workflowBodyFromJson,
|
|
@@ -4837,6 +5166,7 @@ export {
|
|
|
4837
5166
|
renderTodosTaskWorkerVerifierWorkflow,
|
|
4838
5167
|
renderLoopTemplate,
|
|
4839
5168
|
renderEventWorkerVerifierWorkflow,
|
|
5169
|
+
renderBoundedAgentWorkerVerifierWorkflow,
|
|
4840
5170
|
refreshLoopMachine,
|
|
4841
5171
|
readyNodeKeys,
|
|
4842
5172
|
preflightWorkflow,
|
|
@@ -4857,9 +5187,13 @@ export {
|
|
|
4857
5187
|
executeLoop,
|
|
4858
5188
|
computeNextAfter,
|
|
4859
5189
|
classifyRunFailure,
|
|
5190
|
+
buildScriptInventoryReport,
|
|
5191
|
+
buildNameHygieneReport,
|
|
4860
5192
|
buildHealthReport,
|
|
5193
|
+
buildDuplicateOverlapReport,
|
|
4861
5194
|
TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
4862
5195
|
Store,
|
|
4863
5196
|
LoopsClient,
|
|
4864
|
-
EVENT_WORKER_VERIFIER_TEMPLATE_ID
|
|
5197
|
+
EVENT_WORKER_VERIFIER_TEMPLATE_ID,
|
|
5198
|
+
BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID
|
|
4865
5199
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Loop } from "../types.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
export interface NameHygieneChange {
|
|
4
|
+
id: string;
|
|
5
|
+
status: string;
|
|
6
|
+
scope: "machine" | "repo";
|
|
7
|
+
scopeSlug: string;
|
|
8
|
+
oldName: string;
|
|
9
|
+
newName: string;
|
|
10
|
+
changed: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface NameHygieneReport {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
generatedAt: string;
|
|
15
|
+
applied: boolean;
|
|
16
|
+
checked: number;
|
|
17
|
+
changed: number;
|
|
18
|
+
changes: NameHygieneChange[];
|
|
19
|
+
}
|
|
20
|
+
export interface DuplicateOverlapGroup {
|
|
21
|
+
key: string;
|
|
22
|
+
baseName: string;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
schedule: string;
|
|
25
|
+
loops: Array<Pick<Loop, "id" | "name" | "status" | "nextRunAt">>;
|
|
26
|
+
}
|
|
27
|
+
export interface DuplicateOverlapReport {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
generatedAt: string;
|
|
30
|
+
checked: number;
|
|
31
|
+
groups: DuplicateOverlapGroup[];
|
|
32
|
+
}
|
|
33
|
+
export interface ScriptBackedLoop {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
status: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
command: string;
|
|
39
|
+
scriptMatches: string[];
|
|
40
|
+
}
|
|
41
|
+
export interface ScriptInventoryReport {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
generatedAt: string;
|
|
44
|
+
checked: number;
|
|
45
|
+
scriptBacked: number;
|
|
46
|
+
loops: ScriptBackedLoop[];
|
|
47
|
+
}
|
|
48
|
+
export declare function buildNameHygieneReport(store: Store, opts?: {
|
|
49
|
+
apply?: boolean;
|
|
50
|
+
includeStopped?: boolean;
|
|
51
|
+
includeInactive?: boolean;
|
|
52
|
+
limit?: number;
|
|
53
|
+
}): NameHygieneReport;
|
|
54
|
+
export declare function buildDuplicateOverlapReport(store: Store, opts?: {
|
|
55
|
+
includeInactive?: boolean;
|
|
56
|
+
limit?: number;
|
|
57
|
+
}): DuplicateOverlapReport;
|
|
58
|
+
export declare function buildScriptInventoryReport(store: Store, opts?: {
|
|
59
|
+
scriptsDir?: string;
|
|
60
|
+
includeInactive?: boolean;
|
|
61
|
+
limit?: number;
|
|
62
|
+
}): ScriptInventoryReport;
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export declare class Store {
|
|
|
79
79
|
}): Loop[];
|
|
80
80
|
dueLoops(now: Date): Loop[];
|
|
81
81
|
updateLoop(id: string, patch: Partial<Pick<Loop, "status" | "nextRunAt" | "retryScheduledFor" | "expiresAt">>, opts?: DaemonLeaseFence): Loop;
|
|
82
|
+
renameLoop(id: string, name: string, opts?: DaemonLeaseFence): Loop;
|
|
82
83
|
archiveLoop(idOrName: string): Loop;
|
|
83
84
|
unarchiveLoop(idOrName: string): Loop;
|
|
84
85
|
deleteLoop(idOrName: string): boolean;
|
package/dist/lib/store.js
CHANGED
|
@@ -1050,6 +1050,30 @@ class Store {
|
|
|
1050
1050
|
throw new Error(`loop not found after update: ${id}`);
|
|
1051
1051
|
return after;
|
|
1052
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
|
+
}
|
|
1053
1077
|
archiveLoop(idOrName) {
|
|
1054
1078
|
const loop = this.requireLoop(idOrName);
|
|
1055
1079
|
if (loop.archivedAt)
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, CreateWorkflowInput, LoopTemplateSummary } from "../types.js";
|
|
2
2
|
export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
3
3
|
export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
4
|
+
export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
4
5
|
export interface TodosTaskWorkflowTemplateInput {
|
|
5
6
|
taskId: string;
|
|
6
7
|
taskTitle?: string;
|
|
@@ -46,8 +47,30 @@ export interface EventWorkflowTemplateInput {
|
|
|
46
47
|
permissionMode?: AgentPermissionMode;
|
|
47
48
|
sandbox?: AgentSandbox;
|
|
48
49
|
}
|
|
50
|
+
export interface BoundedAgentWorkflowTemplateInput {
|
|
51
|
+
name?: string;
|
|
52
|
+
objective: string;
|
|
53
|
+
prompt?: string;
|
|
54
|
+
projectPath: string;
|
|
55
|
+
provider?: AgentProvider;
|
|
56
|
+
authProfile?: string;
|
|
57
|
+
authProfilePool?: string[];
|
|
58
|
+
workerAuthProfile?: string;
|
|
59
|
+
verifierAuthProfile?: string;
|
|
60
|
+
account?: AccountRef;
|
|
61
|
+
accountPool?: AccountRef[];
|
|
62
|
+
workerAccount?: AccountRef;
|
|
63
|
+
verifierAccount?: AccountRef;
|
|
64
|
+
model?: string;
|
|
65
|
+
variant?: string;
|
|
66
|
+
agent?: string;
|
|
67
|
+
permissionMode?: AgentPermissionMode;
|
|
68
|
+
sandbox?: AgentSandbox;
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
}
|
|
49
71
|
export declare function listLoopTemplates(): LoopTemplateSummary[];
|
|
50
72
|
export declare function getLoopTemplate(id: string): LoopTemplateSummary | undefined;
|
|
51
73
|
export declare function renderTodosTaskWorkerVerifierWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
|
|
52
74
|
export declare function renderEventWorkerVerifierWorkflow(input: EventWorkflowTemplateInput): CreateWorkflowInput;
|
|
75
|
+
export declare function renderBoundedAgentWorkerVerifierWorkflow(input: BoundedAgentWorkflowTemplateInput): CreateWorkflowInput;
|
|
53
76
|
export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>): CreateWorkflowInput;
|
package/dist/sdk/index.js
CHANGED
|
@@ -1050,6 +1050,30 @@ class Store {
|
|
|
1050
1050
|
throw new Error(`loop not found after update: ${id}`);
|
|
1051
1051
|
return after;
|
|
1052
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
|
+
}
|
|
1053
1077
|
archiveLoop(idOrName) {
|
|
1054
1078
|
const loop = this.requireLoop(idOrName);
|
|
1055
1079
|
if (loop.archivedAt)
|
package/docs/USAGE.md
CHANGED
|
@@ -184,7 +184,8 @@ Use `shell: true` only when you intentionally want shell parsing:
|
|
|
184
184
|
Built-in templates turn common orchestration flows into reusable workflow JSON.
|
|
185
185
|
`todos-task-worker-verifier` performs one todos task and then verifies it.
|
|
186
186
|
`event-worker-verifier` handles any Hasna event envelope and then verifies the
|
|
187
|
-
handling.
|
|
187
|
+
handling. `bounded-agent-worker-verifier` is for recurring bounded agent work:
|
|
188
|
+
one worker runs a narrow objective, then a fresh verifier audits the result.
|
|
188
189
|
|
|
189
190
|
```bash
|
|
190
191
|
loops templates list
|
|
@@ -204,6 +205,12 @@ loops templates render event-worker-verifier \
|
|
|
204
205
|
--var eventSource=knowledge \
|
|
205
206
|
--var eventJson='{"id":"<event-id>"}' \
|
|
206
207
|
--var projectPath=/path/to/repo
|
|
208
|
+
loops templates render bounded-agent-worker-verifier \
|
|
209
|
+
--var objective="Check docs drift and queue tasks for gaps" \
|
|
210
|
+
--var projectPath=/path/to/repo \
|
|
211
|
+
--var provider=codewith \
|
|
212
|
+
--var authProfilePool=account004,account005 \
|
|
213
|
+
--var sandbox=danger-full-access
|
|
207
214
|
```
|
|
208
215
|
|
|
209
216
|
For event-driven task automation, `loops events handle todos-task` reads a
|
|
@@ -287,15 +294,42 @@ loops expectations <loop-id-or-name> --json
|
|
|
287
294
|
|
|
288
295
|
The JSON contains the expectation result, bounded error/stdout/stderr evidence,
|
|
289
296
|
a stable failure fingerprint, route metadata, and recommended task fields.
|
|
290
|
-
OpenLoops does not mutate Todos from
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
297
|
+
OpenLoops does not mutate Todos from `health` or `expectations`. To turn failed
|
|
298
|
+
expectations into deduped tasks, use the explicit routing command:
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
loops health route-tasks \
|
|
302
|
+
--project ~/.hasna/loops \
|
|
303
|
+
--task-list loop-error-self-heal \
|
|
304
|
+
--max-actions 5
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Use `--dry-run --json` first when testing a new automation path. Routed tasks
|
|
308
|
+
include the stable failure fingerprint, classification, loop id/name, and
|
|
309
|
+
`no_tmux_dispatch=true` metadata.
|
|
294
310
|
|
|
295
311
|
Failure classifications are: `rate_limit`, `auth`, `model_not_found`,
|
|
296
312
|
`context_length`, `schema_response_format`, `node_init`, `timeout`, `sigsegv`,
|
|
297
313
|
`skipped_previous_active`, and `unknown`.
|
|
298
314
|
|
|
315
|
+
## Hygiene
|
|
316
|
+
|
|
317
|
+
OpenLoops includes deterministic hygiene checks for replacing local maintenance
|
|
318
|
+
scripts with package commands:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
loops hygiene names --json
|
|
322
|
+
loops hygiene names --apply
|
|
323
|
+
loops hygiene duplicates --json
|
|
324
|
+
loops hygiene scripts --json
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
|
|
328
|
+
only renames when `--apply` is present. `hygiene duplicates` groups loops with
|
|
329
|
+
the same normalized name, cwd, and schedule. `hygiene scripts` inventories loops
|
|
330
|
+
whose command still references `~/.hasna/loops/scripts`; use it as a migration
|
|
331
|
+
gate before deleting local scripts.
|
|
332
|
+
|
|
299
333
|
Archive loops when retiring old automation but preserving history:
|
|
300
334
|
|
|
301
335
|
```bash
|
package/package.json
CHANGED