@hasna/loops 0.3.16 → 0.3.18
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 +526 -11
- package/dist/daemon/index.js +25 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +370 -10
- package/dist/lib/health.d.ts +1 -0
- package/dist/lib/hygiene.d.ts +63 -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
|
|
@@ -4615,6 +4640,7 @@ function runDoctor(store) {
|
|
|
4615
4640
|
// src/lib/health.ts
|
|
4616
4641
|
import { createHash } from "crypto";
|
|
4617
4642
|
var EVIDENCE_CHARS = 2000;
|
|
4643
|
+
var FINGERPRINT_EVIDENCE_CHARS = 120;
|
|
4618
4644
|
var CLASSIFICATIONS = [
|
|
4619
4645
|
"rate_limit",
|
|
4620
4646
|
"auth",
|
|
@@ -4643,6 +4669,15 @@ function stableFingerprint(parts) {
|
|
|
4643
4669
|
return createHash("sha256").update(parts.join(`
|
|
4644
4670
|
`)).digest("hex").slice(0, 16);
|
|
4645
4671
|
}
|
|
4672
|
+
function stableFailureFingerprint(run, classification) {
|
|
4673
|
+
return stableFingerprint([
|
|
4674
|
+
run.loopId,
|
|
4675
|
+
classification,
|
|
4676
|
+
String(run.status),
|
|
4677
|
+
String(run.exitCode ?? ""),
|
|
4678
|
+
(run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
|
|
4679
|
+
]);
|
|
4680
|
+
}
|
|
4646
4681
|
function healthRun(run) {
|
|
4647
4682
|
return {
|
|
4648
4683
|
...run,
|
|
@@ -4676,14 +4711,7 @@ function classifyRunFailure(run) {
|
|
|
4676
4711
|
classification = "sigsegv";
|
|
4677
4712
|
return {
|
|
4678
4713
|
classification,
|
|
4679
|
-
fingerprint:
|
|
4680
|
-
run.loopId,
|
|
4681
|
-
run.loopName,
|
|
4682
|
-
run.status,
|
|
4683
|
-
classification,
|
|
4684
|
-
String(run.exitCode ?? ""),
|
|
4685
|
-
(run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
|
|
4686
|
-
]),
|
|
4714
|
+
fingerprint: stableFailureFingerprint(run, classification),
|
|
4687
4715
|
evidence: {
|
|
4688
4716
|
error: bounded(run.error),
|
|
4689
4717
|
stdout: bounded(run.stdout),
|
|
@@ -4799,7 +4827,7 @@ function expectationForLoop(store, loop) {
|
|
|
4799
4827
|
};
|
|
4800
4828
|
}
|
|
4801
4829
|
function buildHealthReport(store, opts = {}) {
|
|
4802
|
-
const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
|
|
4830
|
+
const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 }).filter((loop) => opts.includeInactive || loop.status === "active" || loop.status === "paused");
|
|
4803
4831
|
const expectations = loops.map((loop) => expectationForLoop(store, loop));
|
|
4804
4832
|
const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
|
|
4805
4833
|
for (const expectation of expectations) {
|
|
@@ -4821,10 +4849,247 @@ function buildHealthReport(store, opts = {}) {
|
|
|
4821
4849
|
expectations
|
|
4822
4850
|
};
|
|
4823
4851
|
}
|
|
4852
|
+
|
|
4853
|
+
// src/lib/hygiene.ts
|
|
4854
|
+
import { basename } from "path";
|
|
4855
|
+
var PROVIDER_TOKENS = new Set([
|
|
4856
|
+
"codewith",
|
|
4857
|
+
"claude",
|
|
4858
|
+
"command",
|
|
4859
|
+
"tmux",
|
|
4860
|
+
"codex",
|
|
4861
|
+
"cursor",
|
|
4862
|
+
"opencode",
|
|
4863
|
+
"aicopilot",
|
|
4864
|
+
"agent"
|
|
4865
|
+
]);
|
|
4866
|
+
var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
|
|
4867
|
+
function slugify(value) {
|
|
4868
|
+
return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
4869
|
+
}
|
|
4870
|
+
function repoSlugFromCwd(cwd) {
|
|
4871
|
+
if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
|
|
4872
|
+
return "";
|
|
4873
|
+
if (cwd.includes("/.hasna/loops/"))
|
|
4874
|
+
return "";
|
|
4875
|
+
return slugify(basename(cwd));
|
|
4876
|
+
}
|
|
4877
|
+
function scopeForLoop(loop) {
|
|
4878
|
+
const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
|
|
4879
|
+
const repoSlug = repoSlugFromCwd(cwd);
|
|
4880
|
+
if (repoSlug)
|
|
4881
|
+
return { scope: "repo", prefix: `repo-${repoSlug}`, scopeSlug: repoSlug };
|
|
4882
|
+
return { scope: "machine", prefix: "machine", scopeSlug: "machine" };
|
|
4883
|
+
}
|
|
4884
|
+
function taskSlug(loop, scope) {
|
|
4885
|
+
const oldName = loop.name;
|
|
4886
|
+
let nameForParsing = oldName;
|
|
4887
|
+
if (!oldName.includes(":")) {
|
|
4888
|
+
const slug = slugify(oldName);
|
|
4889
|
+
if (scope.scope === "machine" && slug.startsWith("machine-"))
|
|
4890
|
+
nameForParsing = slug.slice("machine-".length);
|
|
4891
|
+
else if (scope.scope === "repo" && slug.startsWith(`repo-${scope.scopeSlug}-`)) {
|
|
4892
|
+
nameForParsing = slug.slice(`repo-${scope.scopeSlug}-`.length);
|
|
4893
|
+
} else
|
|
4894
|
+
nameForParsing = slug;
|
|
4895
|
+
}
|
|
4896
|
+
const parts = [];
|
|
4897
|
+
for (const rawPart of oldName.includes(":") ? oldName.split(":") : [nameForParsing]) {
|
|
4898
|
+
const part = slugify(rawPart);
|
|
4899
|
+
if (!part)
|
|
4900
|
+
continue;
|
|
4901
|
+
if (PROVIDER_TOKENS.has(part) || /^account\d+$/.test(part))
|
|
4902
|
+
continue;
|
|
4903
|
+
if (scope.scope === "repo" && REPO_GENERIC_TOKENS.has(part))
|
|
4904
|
+
continue;
|
|
4905
|
+
let normalized = part;
|
|
4906
|
+
if (scope.scope === "repo" && normalized === scope.scopeSlug)
|
|
4907
|
+
continue;
|
|
4908
|
+
if (scope.scope === "repo" && normalized.startsWith(`${scope.scopeSlug}-`)) {
|
|
4909
|
+
normalized = normalized.slice(scope.scopeSlug.length + 1);
|
|
4910
|
+
}
|
|
4911
|
+
if (normalized)
|
|
4912
|
+
parts.push(normalized);
|
|
4913
|
+
}
|
|
4914
|
+
const deduped = [];
|
|
4915
|
+
for (const token of parts.join("-").split("-").filter(Boolean)) {
|
|
4916
|
+
if (deduped[deduped.length - 1] !== token)
|
|
4917
|
+
deduped.push(token);
|
|
4918
|
+
}
|
|
4919
|
+
return deduped.join("-") || "loop";
|
|
4920
|
+
}
|
|
4921
|
+
function canonicalName(loop) {
|
|
4922
|
+
const scope = scopeForLoop(loop);
|
|
4923
|
+
let name = `${scope.prefix}-${taskSlug(loop, scope)}`.replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
4924
|
+
if (name.length > 120)
|
|
4925
|
+
name = `${name.slice(0, 111).replace(/-+$/g, "")}-${loop.id.slice(0, 8)}`;
|
|
4926
|
+
return {
|
|
4927
|
+
id: loop.id,
|
|
4928
|
+
status: loop.status,
|
|
4929
|
+
scope: scope.scope,
|
|
4930
|
+
scopeSlug: scope.scopeSlug,
|
|
4931
|
+
newName: name
|
|
4932
|
+
};
|
|
4933
|
+
}
|
|
4934
|
+
function ensureUnique(changes, existingNames = []) {
|
|
4935
|
+
const oldNames = new Set(changes.map((change) => change.oldName));
|
|
4936
|
+
const used = new Set([...existingNames].filter((name) => !oldNames.has(name)));
|
|
4937
|
+
for (const change of changes) {
|
|
4938
|
+
let candidate = change.newName;
|
|
4939
|
+
if (!used.has(candidate)) {
|
|
4940
|
+
used.add(candidate);
|
|
4941
|
+
change.newName = candidate;
|
|
4942
|
+
change.changed = change.oldName !== candidate;
|
|
4943
|
+
continue;
|
|
4944
|
+
}
|
|
4945
|
+
const base = candidate.slice(0, 111).replace(/-+$/g, "");
|
|
4946
|
+
candidate = `${base}-${change.id.slice(0, 8)}`;
|
|
4947
|
+
let suffix = 2;
|
|
4948
|
+
while (used.has(candidate)) {
|
|
4949
|
+
const extra = `-${change.id.slice(0, 6)}-${suffix++}`;
|
|
4950
|
+
candidate = `${base.slice(0, 120 - extra.length)}${extra}`;
|
|
4951
|
+
}
|
|
4952
|
+
used.add(candidate);
|
|
4953
|
+
change.newName = candidate;
|
|
4954
|
+
change.changed = change.oldName !== candidate;
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
function managedLoops(store, opts) {
|
|
4958
|
+
const loops = store.listLoops({ includeArchived: Boolean(opts.includeInactive), limit: opts.limit ?? 1000 });
|
|
4959
|
+
if (opts.includeInactive)
|
|
4960
|
+
return loops;
|
|
4961
|
+
if (opts.includeStopped)
|
|
4962
|
+
return loops.filter((loop) => loop.status !== "expired");
|
|
4963
|
+
return loops.filter((loop) => loop.status === "active" || loop.status === "paused");
|
|
4964
|
+
}
|
|
4965
|
+
function buildNameHygieneReport(store, opts = {}) {
|
|
4966
|
+
const allLoops = store.listLoops({ includeArchived: true, limit: 1e4 });
|
|
4967
|
+
const changes = managedLoops(store, opts).map((loop) => {
|
|
4968
|
+
const canonical = canonicalName(loop);
|
|
4969
|
+
return {
|
|
4970
|
+
...canonical,
|
|
4971
|
+
oldName: loop.name,
|
|
4972
|
+
changed: loop.name !== canonical.newName
|
|
4973
|
+
};
|
|
4974
|
+
});
|
|
4975
|
+
ensureUnique(changes, allLoops.map((loop) => loop.name));
|
|
4976
|
+
const changed = changes.filter((change) => change.changed);
|
|
4977
|
+
const conflicts = changes.filter((change) => allLoops.some((loop) => loop.name === change.newName && loop.id !== change.id));
|
|
4978
|
+
if (opts.apply) {
|
|
4979
|
+
for (const change of changed)
|
|
4980
|
+
store.renameLoop(change.id, change.newName);
|
|
4981
|
+
}
|
|
4982
|
+
return {
|
|
4983
|
+
ok: changed.length === 0,
|
|
4984
|
+
generatedAt: new Date().toISOString(),
|
|
4985
|
+
applied: Boolean(opts.apply),
|
|
4986
|
+
checked: changes.length,
|
|
4987
|
+
changed: changed.length,
|
|
4988
|
+
changes,
|
|
4989
|
+
conflicts
|
|
4990
|
+
};
|
|
4991
|
+
}
|
|
4992
|
+
function baseName(name) {
|
|
4993
|
+
return name.replace(/-(bounded|compact|native)?-?low(?:-\d+m)?$/g, "").replace(/-\d+[mhd]$/g, "").replace(/-(bounded|compact)$/g, "");
|
|
4994
|
+
}
|
|
4995
|
+
function scheduleKey(schedule) {
|
|
4996
|
+
if (schedule.type === "cron")
|
|
4997
|
+
return `cron:${schedule.expression}`;
|
|
4998
|
+
if (schedule.type === "interval")
|
|
4999
|
+
return `interval:${schedule.everyMs}`;
|
|
5000
|
+
if (schedule.type === "once")
|
|
5001
|
+
return `once:${schedule.at}`;
|
|
5002
|
+
return `dynamic:${schedule.minIntervalMs ?? ""}`;
|
|
5003
|
+
}
|
|
5004
|
+
function targetCwd(loop) {
|
|
5005
|
+
return loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd ?? "" : "";
|
|
5006
|
+
}
|
|
5007
|
+
function buildDuplicateOverlapReport(store, opts = {}) {
|
|
5008
|
+
const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
|
|
5009
|
+
const groups = new Map;
|
|
5010
|
+
for (const loop of loops) {
|
|
5011
|
+
const base = baseName(loop.name);
|
|
5012
|
+
const cwd = targetCwd(loop) || undefined;
|
|
5013
|
+
const schedule = scheduleKey(loop.schedule);
|
|
5014
|
+
const key = `${base}|${cwd ?? ""}|${schedule}`;
|
|
5015
|
+
const existing = groups.get(key) ?? { baseName: base, cwd, schedule, loops: [] };
|
|
5016
|
+
existing.loops.push(loop);
|
|
5017
|
+
groups.set(key, existing);
|
|
5018
|
+
}
|
|
5019
|
+
const duplicateGroups = [...groups.entries()].filter(([, group]) => group.loops.length > 1).map(([key, group]) => ({
|
|
5020
|
+
key,
|
|
5021
|
+
baseName: group.baseName,
|
|
5022
|
+
cwd: group.cwd,
|
|
5023
|
+
schedule: group.schedule,
|
|
5024
|
+
loops: group.loops.map((loop) => ({
|
|
5025
|
+
id: loop.id,
|
|
5026
|
+
name: loop.name,
|
|
5027
|
+
status: loop.status,
|
|
5028
|
+
nextRunAt: loop.nextRunAt
|
|
5029
|
+
}))
|
|
5030
|
+
}));
|
|
5031
|
+
return {
|
|
5032
|
+
ok: duplicateGroups.length === 0,
|
|
5033
|
+
generatedAt: new Date().toISOString(),
|
|
5034
|
+
checked: loops.length,
|
|
5035
|
+
groups: duplicateGroups
|
|
5036
|
+
};
|
|
5037
|
+
}
|
|
5038
|
+
function commandText(loop) {
|
|
5039
|
+
if (loop.target.type !== "command")
|
|
5040
|
+
return "";
|
|
5041
|
+
return [loop.target.command, ...loop.target.args ?? []].join(" ");
|
|
5042
|
+
}
|
|
5043
|
+
function scriptNeedles(scriptsDir) {
|
|
5044
|
+
const home = process.env.HOME ?? "/home/hasna";
|
|
5045
|
+
const normalized = scriptsDir.replace(/\/+$/g, "");
|
|
5046
|
+
const values = [
|
|
5047
|
+
normalized,
|
|
5048
|
+
`${normalized}/`,
|
|
5049
|
+
"~/.hasna/loops/scripts",
|
|
5050
|
+
"~/.hasna/loops/scripts/",
|
|
5051
|
+
"$HOME/.hasna/loops/scripts",
|
|
5052
|
+
"$HOME/.hasna/loops/scripts/",
|
|
5053
|
+
"${HOME}/.hasna/loops/scripts",
|
|
5054
|
+
"${HOME}/.hasna/loops/scripts/",
|
|
5055
|
+
`${home}/.hasna/loops/scripts`,
|
|
5056
|
+
`${home}/.hasna/loops/scripts/`,
|
|
5057
|
+
"/.hasna/loops/scripts/"
|
|
5058
|
+
];
|
|
5059
|
+
return [...new Set(values)];
|
|
5060
|
+
}
|
|
5061
|
+
function buildScriptInventoryReport(store, opts = {}) {
|
|
5062
|
+
const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
|
|
5063
|
+
const needles = scriptNeedles(scriptsDir);
|
|
5064
|
+
const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
|
|
5065
|
+
const scriptBacked = loops.map((loop) => {
|
|
5066
|
+
const text = commandText(loop);
|
|
5067
|
+
if (!text)
|
|
5068
|
+
return;
|
|
5069
|
+
const matches = needles.filter((needle) => text.includes(needle));
|
|
5070
|
+
if (!matches.length)
|
|
5071
|
+
return;
|
|
5072
|
+
return {
|
|
5073
|
+
id: loop.id,
|
|
5074
|
+
name: loop.name,
|
|
5075
|
+
status: loop.status,
|
|
5076
|
+
cwd: targetCwd(loop) || undefined,
|
|
5077
|
+
command: text.length > 500 ? `${text.slice(0, 500)}...` : text,
|
|
5078
|
+
scriptMatches: [...new Set(matches)]
|
|
5079
|
+
};
|
|
5080
|
+
}).filter((value) => Boolean(value));
|
|
5081
|
+
return {
|
|
5082
|
+
ok: scriptBacked.length === 0,
|
|
5083
|
+
generatedAt: new Date().toISOString(),
|
|
5084
|
+
checked: loops.length,
|
|
5085
|
+
scriptBacked: scriptBacked.length,
|
|
5086
|
+
loops: scriptBacked
|
|
5087
|
+
};
|
|
5088
|
+
}
|
|
4824
5089
|
// package.json
|
|
4825
5090
|
var package_default = {
|
|
4826
5091
|
name: "@hasna/loops",
|
|
4827
|
-
version: "0.3.
|
|
5092
|
+
version: "0.3.18",
|
|
4828
5093
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4829
5094
|
type: "module",
|
|
4830
5095
|
main: "dist/index.js",
|
|
@@ -4915,6 +5180,7 @@ function packageVersion() {
|
|
|
4915
5180
|
// src/lib/templates.ts
|
|
4916
5181
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
4917
5182
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
5183
|
+
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
4918
5184
|
var TEMPLATE_SUMMARIES = [
|
|
4919
5185
|
{
|
|
4920
5186
|
id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
@@ -4959,6 +5225,28 @@ var TEMPLATE_SUMMARIES = [
|
|
|
4959
5225
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
4960
5226
|
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
|
|
4961
5227
|
]
|
|
5228
|
+
},
|
|
5229
|
+
{
|
|
5230
|
+
id: BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID,
|
|
5231
|
+
name: "Bounded Agent Worker + Verifier",
|
|
5232
|
+
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.",
|
|
5233
|
+
kind: "workflow",
|
|
5234
|
+
variables: [
|
|
5235
|
+
{ name: "objective", required: true, description: "Narrow goal-mode objective for the worker." },
|
|
5236
|
+
{ name: "prompt", description: "Optional extra worker prompt details." },
|
|
5237
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5238
|
+
{ name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
|
|
5239
|
+
{ name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
|
|
5240
|
+
{ name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
|
|
5241
|
+
{ name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
|
|
5242
|
+
{ name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
|
|
5243
|
+
{ name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
|
|
5244
|
+
{ name: "model", description: "Provider model." },
|
|
5245
|
+
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5246
|
+
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5247
|
+
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
|
|
5248
|
+
{ name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
|
|
5249
|
+
]
|
|
4962
5250
|
}
|
|
4963
5251
|
];
|
|
4964
5252
|
function compactJson(value) {
|
|
@@ -5145,6 +5433,54 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5145
5433
|
]
|
|
5146
5434
|
};
|
|
5147
5435
|
}
|
|
5436
|
+
function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
5437
|
+
if (!input.objective?.trim())
|
|
5438
|
+
throw new Error("objective is required");
|
|
5439
|
+
if (!input.projectPath?.trim())
|
|
5440
|
+
throw new Error("projectPath is required");
|
|
5441
|
+
const seed = `${input.projectPath}:${input.objective}`;
|
|
5442
|
+
const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
|
|
5443
|
+
const workerPrompt = [
|
|
5444
|
+
`/goal ${input.objective}`,
|
|
5445
|
+
"",
|
|
5446
|
+
"You are the worker step for a bounded OpenLoops agent workflow.",
|
|
5447
|
+
"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.",
|
|
5448
|
+
"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.",
|
|
5449
|
+
input.prompt ? "" : undefined,
|
|
5450
|
+
input.prompt
|
|
5451
|
+
].filter(Boolean).join(`
|
|
5452
|
+
`);
|
|
5453
|
+
const verifierPrompt = [
|
|
5454
|
+
`/goal Adversarially verify: ${input.objective}`,
|
|
5455
|
+
"",
|
|
5456
|
+
"You are the verifier step for a bounded OpenLoops agent workflow.",
|
|
5457
|
+
"Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
|
|
5458
|
+
"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."
|
|
5459
|
+
].join(`
|
|
5460
|
+
`);
|
|
5461
|
+
return {
|
|
5462
|
+
name: input.name ?? `bounded-agent-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
|
|
5463
|
+
description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
|
|
5464
|
+
version: 1,
|
|
5465
|
+
steps: [
|
|
5466
|
+
{
|
|
5467
|
+
id: "worker",
|
|
5468
|
+
name: "Worker",
|
|
5469
|
+
description: "Execute the bounded objective and record evidence.",
|
|
5470
|
+
target: agentTarget(input, workerPrompt, "worker", seed),
|
|
5471
|
+
timeoutMs
|
|
5472
|
+
},
|
|
5473
|
+
{
|
|
5474
|
+
id: "verifier",
|
|
5475
|
+
name: "Verifier",
|
|
5476
|
+
description: "Adversarially verify the bounded objective result.",
|
|
5477
|
+
dependsOn: ["worker"],
|
|
5478
|
+
target: agentTarget(input, verifierPrompt, "verifier", seed),
|
|
5479
|
+
timeoutMs: Math.min(timeoutMs, 30 * 60000)
|
|
5480
|
+
}
|
|
5481
|
+
]
|
|
5482
|
+
};
|
|
5483
|
+
}
|
|
5148
5484
|
function renderLoopTemplate(id, values) {
|
|
5149
5485
|
if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
5150
5486
|
return renderTodosTaskWorkerVerifierWorkflow({
|
|
@@ -5191,6 +5527,27 @@ function renderLoopTemplate(id, values) {
|
|
|
5191
5527
|
sandbox: values.sandbox
|
|
5192
5528
|
});
|
|
5193
5529
|
}
|
|
5530
|
+
if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
5531
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
5532
|
+
name: values.name,
|
|
5533
|
+
objective: values.objective ?? "",
|
|
5534
|
+
prompt: values.prompt,
|
|
5535
|
+
projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
|
|
5536
|
+
provider: values.provider,
|
|
5537
|
+
authProfile: values.authProfile,
|
|
5538
|
+
authProfilePool: listVar(values.authProfilePool),
|
|
5539
|
+
workerAuthProfile: values.workerAuthProfile,
|
|
5540
|
+
verifierAuthProfile: values.verifierAuthProfile,
|
|
5541
|
+
account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
|
|
5542
|
+
accountPool: accountPoolVar(values.accountPool, values.accountTool),
|
|
5543
|
+
model: values.model,
|
|
5544
|
+
variant: values.variant,
|
|
5545
|
+
agent: values.agent,
|
|
5546
|
+
permissionMode: values.permissionMode,
|
|
5547
|
+
sandbox: values.sandbox,
|
|
5548
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
|
|
5549
|
+
});
|
|
5550
|
+
}
|
|
5194
5551
|
throw new Error(`unknown template: ${id}`);
|
|
5195
5552
|
}
|
|
5196
5553
|
function listVar(value) {
|
|
@@ -5355,6 +5712,36 @@ function collectValues(value, previous = []) {
|
|
|
5355
5712
|
previous.push(value);
|
|
5356
5713
|
return previous;
|
|
5357
5714
|
}
|
|
5715
|
+
function defaultLoopsProject() {
|
|
5716
|
+
return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME ?? "/home/hasna"}/.hasna/loops`;
|
|
5717
|
+
}
|
|
5718
|
+
function runLocalCommand(command, args, opts = {}) {
|
|
5719
|
+
const result = spawnSync5(command, args, {
|
|
5720
|
+
input: opts.input,
|
|
5721
|
+
encoding: "utf8",
|
|
5722
|
+
timeout: opts.timeoutMs ?? 30000,
|
|
5723
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
5724
|
+
env: process.env
|
|
5725
|
+
});
|
|
5726
|
+
return {
|
|
5727
|
+
ok: result.status === 0,
|
|
5728
|
+
status: result.status,
|
|
5729
|
+
stdout: result.stdout || "",
|
|
5730
|
+
stderr: result.stderr || "",
|
|
5731
|
+
error: result.error ? String(result.error.message || result.error) : ""
|
|
5732
|
+
};
|
|
5733
|
+
}
|
|
5734
|
+
function ensureTodosTaskList(project, slug, name, description) {
|
|
5735
|
+
runLocalCommand("todos", ["--project", project, "task-lists", "--add", name, "--slug", slug, "-d", description]);
|
|
5736
|
+
const list = runLocalCommand("todos", ["--project", project, "--json", "task-lists"]);
|
|
5737
|
+
if (!list.ok)
|
|
5738
|
+
throw new Error(list.stderr || list.error || "failed to list todos task lists");
|
|
5739
|
+
const values = JSON.parse(list.stdout || "[]");
|
|
5740
|
+
const found = values.find((entry) => entry.slug === slug);
|
|
5741
|
+
if (!found)
|
|
5742
|
+
throw new Error(`todos task list not found after ensure: ${slug}`);
|
|
5743
|
+
return found.id;
|
|
5744
|
+
}
|
|
5358
5745
|
function eventData(event) {
|
|
5359
5746
|
const data = event.data;
|
|
5360
5747
|
if (data && typeof data === "object" && !Array.isArray(data))
|
|
@@ -6089,7 +6476,7 @@ program.command("expectations [idOrName]").description("evaluate deterministic l
|
|
|
6089
6476
|
store.close();
|
|
6090
6477
|
}
|
|
6091
6478
|
});
|
|
6092
|
-
program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
|
|
6479
|
+
var health = program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
|
|
6093
6480
|
const store = new Store;
|
|
6094
6481
|
try {
|
|
6095
6482
|
const report = buildHealthReport(store);
|
|
@@ -6107,6 +6494,134 @@ program.command("health").description("summarize loop health and latest-run expe
|
|
|
6107
6494
|
store.close();
|
|
6108
6495
|
}
|
|
6109
6496
|
});
|
|
6497
|
+
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("--include-inactive", "also route stopped or expired loops").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
|
|
6498
|
+
const store = new Store;
|
|
6499
|
+
try {
|
|
6500
|
+
const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
|
|
6501
|
+
const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask).slice(0, Number(opts.maxActions));
|
|
6502
|
+
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.");
|
|
6503
|
+
const actions = failures.map((expectation) => {
|
|
6504
|
+
const task = expectation.recommendedTask;
|
|
6505
|
+
const metadata = {
|
|
6506
|
+
source: "openloops.health.route-tasks",
|
|
6507
|
+
loop_id: expectation.loop.id,
|
|
6508
|
+
loop_name: expectation.loop.name,
|
|
6509
|
+
run_id: expectation.latestRun?.id,
|
|
6510
|
+
classification: expectation.failure?.classification,
|
|
6511
|
+
fingerprint: task.dedupeKey,
|
|
6512
|
+
no_tmux_dispatch: true
|
|
6513
|
+
};
|
|
6514
|
+
if (opts.dryRun) {
|
|
6515
|
+
return { action: "would-upsert", title: task.title, fingerprint: task.dedupeKey, priority: task.priority, metadata };
|
|
6516
|
+
}
|
|
6517
|
+
const result = runLocalCommand("todos", [
|
|
6518
|
+
"--project",
|
|
6519
|
+
opts.project,
|
|
6520
|
+
"--json",
|
|
6521
|
+
"task",
|
|
6522
|
+
"upsert",
|
|
6523
|
+
"--fingerprint",
|
|
6524
|
+
task.dedupeKey,
|
|
6525
|
+
"--title",
|
|
6526
|
+
task.title,
|
|
6527
|
+
"-d",
|
|
6528
|
+
task.description,
|
|
6529
|
+
"--priority",
|
|
6530
|
+
task.priority,
|
|
6531
|
+
"--status",
|
|
6532
|
+
"pending",
|
|
6533
|
+
"--list",
|
|
6534
|
+
listId,
|
|
6535
|
+
"--tags",
|
|
6536
|
+
task.tags.join(","),
|
|
6537
|
+
"--metadata-json",
|
|
6538
|
+
JSON.stringify(metadata)
|
|
6539
|
+
]);
|
|
6540
|
+
if (!result.ok) {
|
|
6541
|
+
return { action: "upsert-failed", fingerprint: task.dedupeKey, error: result.stderr || result.error || result.stdout };
|
|
6542
|
+
}
|
|
6543
|
+
return { action: "upserted", fingerprint: task.dedupeKey, task: JSON.parse(result.stdout || "{}") };
|
|
6544
|
+
});
|
|
6545
|
+
const routed = { ok: actions.every((action) => action.action !== "upsert-failed"), inspected: report.summary.loops, failures: failures.length, actions };
|
|
6546
|
+
if (isJson() || opts.json)
|
|
6547
|
+
console.log(JSON.stringify(routed, null, 2));
|
|
6548
|
+
else {
|
|
6549
|
+
console.log(`health_route_tasks inspected=${routed.inspected} failures=${routed.failures} actions=${actions.length}`);
|
|
6550
|
+
for (const action of actions)
|
|
6551
|
+
console.log(`${action.action} ${action.fingerprint}`);
|
|
6552
|
+
}
|
|
6553
|
+
if (!routed.ok)
|
|
6554
|
+
process.exitCode = 1;
|
|
6555
|
+
} finally {
|
|
6556
|
+
store.close();
|
|
6557
|
+
}
|
|
6558
|
+
});
|
|
6559
|
+
var hygiene = program.command("hygiene").description("deterministic OpenLoops hygiene checks and safe repairs");
|
|
6560
|
+
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) => {
|
|
6561
|
+
const store = new Store;
|
|
6562
|
+
try {
|
|
6563
|
+
const report = buildNameHygieneReport(store, {
|
|
6564
|
+
apply: Boolean(opts.apply),
|
|
6565
|
+
includeStopped: Boolean(opts.includeStopped),
|
|
6566
|
+
includeInactive: Boolean(opts.includeInactive),
|
|
6567
|
+
limit: Number(opts.limit)
|
|
6568
|
+
});
|
|
6569
|
+
if (isJson() || opts.json)
|
|
6570
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6571
|
+
else {
|
|
6572
|
+
console.log(`hygiene_names checked=${report.checked} changed=${report.changed} applied=${report.applied}`);
|
|
6573
|
+
for (const change of report.changes.filter((entry) => entry.changed)) {
|
|
6574
|
+
console.log(`${report.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
|
|
6575
|
+
}
|
|
6576
|
+
}
|
|
6577
|
+
if (!report.ok && !report.applied)
|
|
6578
|
+
process.exitCode = 1;
|
|
6579
|
+
} finally {
|
|
6580
|
+
store.close();
|
|
6581
|
+
}
|
|
6582
|
+
});
|
|
6583
|
+
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) => {
|
|
6584
|
+
const store = new Store;
|
|
6585
|
+
try {
|
|
6586
|
+
const report = buildDuplicateOverlapReport(store, {
|
|
6587
|
+
includeInactive: Boolean(opts.includeInactive),
|
|
6588
|
+
limit: Number(opts.limit)
|
|
6589
|
+
});
|
|
6590
|
+
if (isJson() || opts.json)
|
|
6591
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6592
|
+
else {
|
|
6593
|
+
console.log(`hygiene_duplicates checked=${report.checked} groups=${report.groups.length}`);
|
|
6594
|
+
for (const group of report.groups) {
|
|
6595
|
+
console.log(`${group.key} ${group.loops.map((loop) => `${loop.id}:${loop.status}:${loop.name}`).join(",")}`);
|
|
6596
|
+
}
|
|
6597
|
+
}
|
|
6598
|
+
if (!report.ok)
|
|
6599
|
+
process.exitCode = 1;
|
|
6600
|
+
} finally {
|
|
6601
|
+
store.close();
|
|
6602
|
+
}
|
|
6603
|
+
});
|
|
6604
|
+
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) => {
|
|
6605
|
+
const store = new Store;
|
|
6606
|
+
try {
|
|
6607
|
+
const report = buildScriptInventoryReport(store, {
|
|
6608
|
+
scriptsDir: opts.scriptsDir,
|
|
6609
|
+
includeInactive: Boolean(opts.includeInactive),
|
|
6610
|
+
limit: Number(opts.limit)
|
|
6611
|
+
});
|
|
6612
|
+
if (isJson() || opts.json)
|
|
6613
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6614
|
+
else {
|
|
6615
|
+
console.log(`hygiene_scripts checked=${report.checked} script_backed=${report.scriptBacked}`);
|
|
6616
|
+
for (const loop of report.loops)
|
|
6617
|
+
console.log(`${loop.id} ${loop.status} ${loop.name} ${loop.command}`);
|
|
6618
|
+
}
|
|
6619
|
+
if (!report.ok)
|
|
6620
|
+
process.exitCode = 1;
|
|
6621
|
+
} finally {
|
|
6622
|
+
store.close();
|
|
6623
|
+
}
|
|
6624
|
+
});
|
|
6110
6625
|
program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
|
|
6111
6626
|
program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
|
|
6112
6627
|
program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
|