@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/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.18",
|
|
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, 4294967295).toString(16).padStart(8, "0")}-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) {
|
|
@@ -4619,6 +4735,7 @@ function runDoctor(store) {
|
|
|
4619
4735
|
// src/lib/health.ts
|
|
4620
4736
|
import { createHash } from "crypto";
|
|
4621
4737
|
var EVIDENCE_CHARS = 2000;
|
|
4738
|
+
var FINGERPRINT_EVIDENCE_CHARS = 120;
|
|
4622
4739
|
var CLASSIFICATIONS = [
|
|
4623
4740
|
"rate_limit",
|
|
4624
4741
|
"auth",
|
|
@@ -4647,6 +4764,15 @@ function stableFingerprint(parts) {
|
|
|
4647
4764
|
return createHash("sha256").update(parts.join(`
|
|
4648
4765
|
`)).digest("hex").slice(0, 16);
|
|
4649
4766
|
}
|
|
4767
|
+
function stableFailureFingerprint(run, classification) {
|
|
4768
|
+
return stableFingerprint([
|
|
4769
|
+
run.loopId,
|
|
4770
|
+
classification,
|
|
4771
|
+
String(run.status),
|
|
4772
|
+
String(run.exitCode ?? ""),
|
|
4773
|
+
(run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
|
|
4774
|
+
]);
|
|
4775
|
+
}
|
|
4650
4776
|
function healthRun(run) {
|
|
4651
4777
|
return {
|
|
4652
4778
|
...run,
|
|
@@ -4680,14 +4806,7 @@ function classifyRunFailure(run) {
|
|
|
4680
4806
|
classification = "sigsegv";
|
|
4681
4807
|
return {
|
|
4682
4808
|
classification,
|
|
4683
|
-
fingerprint:
|
|
4684
|
-
run.loopId,
|
|
4685
|
-
run.loopName,
|
|
4686
|
-
run.status,
|
|
4687
|
-
classification,
|
|
4688
|
-
String(run.exitCode ?? ""),
|
|
4689
|
-
(run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
|
|
4690
|
-
]),
|
|
4809
|
+
fingerprint: stableFailureFingerprint(run, classification),
|
|
4691
4810
|
evidence: {
|
|
4692
4811
|
error: bounded(run.error),
|
|
4693
4812
|
stdout: bounded(run.stdout),
|
|
@@ -4803,7 +4922,7 @@ function expectationForLoop(store, loop) {
|
|
|
4803
4922
|
};
|
|
4804
4923
|
}
|
|
4805
4924
|
function buildHealthReport(store, opts = {}) {
|
|
4806
|
-
const loops2 = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
|
|
4925
|
+
const loops2 = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 }).filter((loop) => opts.includeInactive || loop.status === "active" || loop.status === "paused");
|
|
4807
4926
|
const expectations = loops2.map((loop) => expectationForLoop(store, loop));
|
|
4808
4927
|
const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
|
|
4809
4928
|
for (const expectation of expectations) {
|
|
@@ -4825,6 +4944,242 @@ function buildHealthReport(store, opts = {}) {
|
|
|
4825
4944
|
expectations
|
|
4826
4945
|
};
|
|
4827
4946
|
}
|
|
4947
|
+
// src/lib/hygiene.ts
|
|
4948
|
+
import { basename } from "path";
|
|
4949
|
+
var PROVIDER_TOKENS = new Set([
|
|
4950
|
+
"codewith",
|
|
4951
|
+
"claude",
|
|
4952
|
+
"command",
|
|
4953
|
+
"tmux",
|
|
4954
|
+
"codex",
|
|
4955
|
+
"cursor",
|
|
4956
|
+
"opencode",
|
|
4957
|
+
"aicopilot",
|
|
4958
|
+
"agent"
|
|
4959
|
+
]);
|
|
4960
|
+
var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
|
|
4961
|
+
function slugify(value) {
|
|
4962
|
+
return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
4963
|
+
}
|
|
4964
|
+
function repoSlugFromCwd(cwd) {
|
|
4965
|
+
if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
|
|
4966
|
+
return "";
|
|
4967
|
+
if (cwd.includes("/.hasna/loops/"))
|
|
4968
|
+
return "";
|
|
4969
|
+
return slugify(basename(cwd));
|
|
4970
|
+
}
|
|
4971
|
+
function scopeForLoop(loop) {
|
|
4972
|
+
const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
|
|
4973
|
+
const repoSlug = repoSlugFromCwd(cwd);
|
|
4974
|
+
if (repoSlug)
|
|
4975
|
+
return { scope: "repo", prefix: `repo-${repoSlug}`, scopeSlug: repoSlug };
|
|
4976
|
+
return { scope: "machine", prefix: "machine", scopeSlug: "machine" };
|
|
4977
|
+
}
|
|
4978
|
+
function taskSlug(loop, scope) {
|
|
4979
|
+
const oldName = loop.name;
|
|
4980
|
+
let nameForParsing = oldName;
|
|
4981
|
+
if (!oldName.includes(":")) {
|
|
4982
|
+
const slug = slugify(oldName);
|
|
4983
|
+
if (scope.scope === "machine" && slug.startsWith("machine-"))
|
|
4984
|
+
nameForParsing = slug.slice("machine-".length);
|
|
4985
|
+
else if (scope.scope === "repo" && slug.startsWith(`repo-${scope.scopeSlug}-`)) {
|
|
4986
|
+
nameForParsing = slug.slice(`repo-${scope.scopeSlug}-`.length);
|
|
4987
|
+
} else
|
|
4988
|
+
nameForParsing = slug;
|
|
4989
|
+
}
|
|
4990
|
+
const parts = [];
|
|
4991
|
+
for (const rawPart of oldName.includes(":") ? oldName.split(":") : [nameForParsing]) {
|
|
4992
|
+
const part = slugify(rawPart);
|
|
4993
|
+
if (!part)
|
|
4994
|
+
continue;
|
|
4995
|
+
if (PROVIDER_TOKENS.has(part) || /^account\d+$/.test(part))
|
|
4996
|
+
continue;
|
|
4997
|
+
if (scope.scope === "repo" && REPO_GENERIC_TOKENS.has(part))
|
|
4998
|
+
continue;
|
|
4999
|
+
let normalized = part;
|
|
5000
|
+
if (scope.scope === "repo" && normalized === scope.scopeSlug)
|
|
5001
|
+
continue;
|
|
5002
|
+
if (scope.scope === "repo" && normalized.startsWith(`${scope.scopeSlug}-`)) {
|
|
5003
|
+
normalized = normalized.slice(scope.scopeSlug.length + 1);
|
|
5004
|
+
}
|
|
5005
|
+
if (normalized)
|
|
5006
|
+
parts.push(normalized);
|
|
5007
|
+
}
|
|
5008
|
+
const deduped = [];
|
|
5009
|
+
for (const token of parts.join("-").split("-").filter(Boolean)) {
|
|
5010
|
+
if (deduped[deduped.length - 1] !== token)
|
|
5011
|
+
deduped.push(token);
|
|
5012
|
+
}
|
|
5013
|
+
return deduped.join("-") || "loop";
|
|
5014
|
+
}
|
|
5015
|
+
function canonicalName(loop) {
|
|
5016
|
+
const scope = scopeForLoop(loop);
|
|
5017
|
+
let name = `${scope.prefix}-${taskSlug(loop, scope)}`.replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
5018
|
+
if (name.length > 120)
|
|
5019
|
+
name = `${name.slice(0, 111).replace(/-+$/g, "")}-${loop.id.slice(0, 8)}`;
|
|
5020
|
+
return {
|
|
5021
|
+
id: loop.id,
|
|
5022
|
+
status: loop.status,
|
|
5023
|
+
scope: scope.scope,
|
|
5024
|
+
scopeSlug: scope.scopeSlug,
|
|
5025
|
+
newName: name
|
|
5026
|
+
};
|
|
5027
|
+
}
|
|
5028
|
+
function ensureUnique(changes, existingNames = []) {
|
|
5029
|
+
const oldNames = new Set(changes.map((change) => change.oldName));
|
|
5030
|
+
const used = new Set([...existingNames].filter((name) => !oldNames.has(name)));
|
|
5031
|
+
for (const change of changes) {
|
|
5032
|
+
let candidate = change.newName;
|
|
5033
|
+
if (!used.has(candidate)) {
|
|
5034
|
+
used.add(candidate);
|
|
5035
|
+
change.newName = candidate;
|
|
5036
|
+
change.changed = change.oldName !== candidate;
|
|
5037
|
+
continue;
|
|
5038
|
+
}
|
|
5039
|
+
const base = candidate.slice(0, 111).replace(/-+$/g, "");
|
|
5040
|
+
candidate = `${base}-${change.id.slice(0, 8)}`;
|
|
5041
|
+
let suffix = 2;
|
|
5042
|
+
while (used.has(candidate)) {
|
|
5043
|
+
const extra = `-${change.id.slice(0, 6)}-${suffix++}`;
|
|
5044
|
+
candidate = `${base.slice(0, 120 - extra.length)}${extra}`;
|
|
5045
|
+
}
|
|
5046
|
+
used.add(candidate);
|
|
5047
|
+
change.newName = candidate;
|
|
5048
|
+
change.changed = change.oldName !== candidate;
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
5051
|
+
function managedLoops(store, opts) {
|
|
5052
|
+
const loops2 = store.listLoops({ includeArchived: Boolean(opts.includeInactive), limit: opts.limit ?? 1000 });
|
|
5053
|
+
if (opts.includeInactive)
|
|
5054
|
+
return loops2;
|
|
5055
|
+
if (opts.includeStopped)
|
|
5056
|
+
return loops2.filter((loop) => loop.status !== "expired");
|
|
5057
|
+
return loops2.filter((loop) => loop.status === "active" || loop.status === "paused");
|
|
5058
|
+
}
|
|
5059
|
+
function buildNameHygieneReport(store, opts = {}) {
|
|
5060
|
+
const allLoops = store.listLoops({ includeArchived: true, limit: 1e4 });
|
|
5061
|
+
const changes = managedLoops(store, opts).map((loop) => {
|
|
5062
|
+
const canonical = canonicalName(loop);
|
|
5063
|
+
return {
|
|
5064
|
+
...canonical,
|
|
5065
|
+
oldName: loop.name,
|
|
5066
|
+
changed: loop.name !== canonical.newName
|
|
5067
|
+
};
|
|
5068
|
+
});
|
|
5069
|
+
ensureUnique(changes, allLoops.map((loop) => loop.name));
|
|
5070
|
+
const changed = changes.filter((change) => change.changed);
|
|
5071
|
+
const conflicts = changes.filter((change) => allLoops.some((loop) => loop.name === change.newName && loop.id !== change.id));
|
|
5072
|
+
if (opts.apply) {
|
|
5073
|
+
for (const change of changed)
|
|
5074
|
+
store.renameLoop(change.id, change.newName);
|
|
5075
|
+
}
|
|
5076
|
+
return {
|
|
5077
|
+
ok: changed.length === 0,
|
|
5078
|
+
generatedAt: new Date().toISOString(),
|
|
5079
|
+
applied: Boolean(opts.apply),
|
|
5080
|
+
checked: changes.length,
|
|
5081
|
+
changed: changed.length,
|
|
5082
|
+
changes,
|
|
5083
|
+
conflicts
|
|
5084
|
+
};
|
|
5085
|
+
}
|
|
5086
|
+
function baseName(name) {
|
|
5087
|
+
return name.replace(/-(bounded|compact|native)?-?low(?:-\d+m)?$/g, "").replace(/-\d+[mhd]$/g, "").replace(/-(bounded|compact)$/g, "");
|
|
5088
|
+
}
|
|
5089
|
+
function scheduleKey(schedule) {
|
|
5090
|
+
if (schedule.type === "cron")
|
|
5091
|
+
return `cron:${schedule.expression}`;
|
|
5092
|
+
if (schedule.type === "interval")
|
|
5093
|
+
return `interval:${schedule.everyMs}`;
|
|
5094
|
+
if (schedule.type === "once")
|
|
5095
|
+
return `once:${schedule.at}`;
|
|
5096
|
+
return `dynamic:${schedule.minIntervalMs ?? ""}`;
|
|
5097
|
+
}
|
|
5098
|
+
function targetCwd(loop) {
|
|
5099
|
+
return loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd ?? "" : "";
|
|
5100
|
+
}
|
|
5101
|
+
function buildDuplicateOverlapReport(store, opts = {}) {
|
|
5102
|
+
const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
|
|
5103
|
+
const groups = new Map;
|
|
5104
|
+
for (const loop of loops2) {
|
|
5105
|
+
const base = baseName(loop.name);
|
|
5106
|
+
const cwd = targetCwd(loop) || undefined;
|
|
5107
|
+
const schedule = scheduleKey(loop.schedule);
|
|
5108
|
+
const key = `${base}|${cwd ?? ""}|${schedule}`;
|
|
5109
|
+
const existing = groups.get(key) ?? { baseName: base, cwd, schedule, loops: [] };
|
|
5110
|
+
existing.loops.push(loop);
|
|
5111
|
+
groups.set(key, existing);
|
|
5112
|
+
}
|
|
5113
|
+
const duplicateGroups = [...groups.entries()].filter(([, group]) => group.loops.length > 1).map(([key, group]) => ({
|
|
5114
|
+
key,
|
|
5115
|
+
baseName: group.baseName,
|
|
5116
|
+
cwd: group.cwd,
|
|
5117
|
+
schedule: group.schedule,
|
|
5118
|
+
loops: group.loops.map((loop) => ({
|
|
5119
|
+
id: loop.id,
|
|
5120
|
+
name: loop.name,
|
|
5121
|
+
status: loop.status,
|
|
5122
|
+
nextRunAt: loop.nextRunAt
|
|
5123
|
+
}))
|
|
5124
|
+
}));
|
|
5125
|
+
return {
|
|
5126
|
+
ok: duplicateGroups.length === 0,
|
|
5127
|
+
generatedAt: new Date().toISOString(),
|
|
5128
|
+
checked: loops2.length,
|
|
5129
|
+
groups: duplicateGroups
|
|
5130
|
+
};
|
|
5131
|
+
}
|
|
5132
|
+
function commandText(loop) {
|
|
5133
|
+
if (loop.target.type !== "command")
|
|
5134
|
+
return "";
|
|
5135
|
+
return [loop.target.command, ...loop.target.args ?? []].join(" ");
|
|
5136
|
+
}
|
|
5137
|
+
function scriptNeedles(scriptsDir) {
|
|
5138
|
+
const home = process.env.HOME ?? "/home/hasna";
|
|
5139
|
+
const normalized = scriptsDir.replace(/\/+$/g, "");
|
|
5140
|
+
const values = [
|
|
5141
|
+
normalized,
|
|
5142
|
+
`${normalized}/`,
|
|
5143
|
+
"~/.hasna/loops/scripts",
|
|
5144
|
+
"~/.hasna/loops/scripts/",
|
|
5145
|
+
"$HOME/.hasna/loops/scripts",
|
|
5146
|
+
"$HOME/.hasna/loops/scripts/",
|
|
5147
|
+
"${HOME}/.hasna/loops/scripts",
|
|
5148
|
+
"${HOME}/.hasna/loops/scripts/",
|
|
5149
|
+
`${home}/.hasna/loops/scripts`,
|
|
5150
|
+
`${home}/.hasna/loops/scripts/`,
|
|
5151
|
+
"/.hasna/loops/scripts/"
|
|
5152
|
+
];
|
|
5153
|
+
return [...new Set(values)];
|
|
5154
|
+
}
|
|
5155
|
+
function buildScriptInventoryReport(store, opts = {}) {
|
|
5156
|
+
const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
|
|
5157
|
+
const needles = scriptNeedles(scriptsDir);
|
|
5158
|
+
const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
|
|
5159
|
+
const scriptBacked = loops2.map((loop) => {
|
|
5160
|
+
const text = commandText(loop);
|
|
5161
|
+
if (!text)
|
|
5162
|
+
return;
|
|
5163
|
+
const matches = needles.filter((needle) => text.includes(needle));
|
|
5164
|
+
if (!matches.length)
|
|
5165
|
+
return;
|
|
5166
|
+
return {
|
|
5167
|
+
id: loop.id,
|
|
5168
|
+
name: loop.name,
|
|
5169
|
+
status: loop.status,
|
|
5170
|
+
cwd: targetCwd(loop) || undefined,
|
|
5171
|
+
command: text.length > 500 ? `${text.slice(0, 500)}...` : text,
|
|
5172
|
+
scriptMatches: [...new Set(matches)]
|
|
5173
|
+
};
|
|
5174
|
+
}).filter((value) => Boolean(value));
|
|
5175
|
+
return {
|
|
5176
|
+
ok: scriptBacked.length === 0,
|
|
5177
|
+
generatedAt: new Date().toISOString(),
|
|
5178
|
+
checked: loops2.length,
|
|
5179
|
+
scriptBacked: scriptBacked.length,
|
|
5180
|
+
loops: scriptBacked
|
|
5181
|
+
};
|
|
5182
|
+
}
|
|
4828
5183
|
export {
|
|
4829
5184
|
workflowExecutionOrder,
|
|
4830
5185
|
workflowBodyFromJson,
|
|
@@ -4837,6 +5192,7 @@ export {
|
|
|
4837
5192
|
renderTodosTaskWorkerVerifierWorkflow,
|
|
4838
5193
|
renderLoopTemplate,
|
|
4839
5194
|
renderEventWorkerVerifierWorkflow,
|
|
5195
|
+
renderBoundedAgentWorkerVerifierWorkflow,
|
|
4840
5196
|
refreshLoopMachine,
|
|
4841
5197
|
readyNodeKeys,
|
|
4842
5198
|
preflightWorkflow,
|
|
@@ -4857,9 +5213,13 @@ export {
|
|
|
4857
5213
|
executeLoop,
|
|
4858
5214
|
computeNextAfter,
|
|
4859
5215
|
classifyRunFailure,
|
|
5216
|
+
buildScriptInventoryReport,
|
|
5217
|
+
buildNameHygieneReport,
|
|
4860
5218
|
buildHealthReport,
|
|
5219
|
+
buildDuplicateOverlapReport,
|
|
4861
5220
|
TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
4862
5221
|
Store,
|
|
4863
5222
|
LoopsClient,
|
|
4864
|
-
EVENT_WORKER_VERIFIER_TEMPLATE_ID
|
|
5223
|
+
EVENT_WORKER_VERIFIER_TEMPLATE_ID,
|
|
5224
|
+
BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID
|
|
4865
5225
|
};
|
package/dist/lib/health.d.ts
CHANGED
|
@@ -66,5 +66,6 @@ export declare function classifyRunFailure(run: LoopRun): RunFailureSignal | und
|
|
|
66
66
|
export declare function expectationForLoop(store: Store, loop: Loop): LoopExpectationResult;
|
|
67
67
|
export declare function buildHealthReport(store: Store, opts?: {
|
|
68
68
|
includeArchived?: boolean;
|
|
69
|
+
includeInactive?: boolean;
|
|
69
70
|
limit?: number;
|
|
70
71
|
}): LoopsHealthReport;
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
conflicts: NameHygieneChange[];
|
|
20
|
+
}
|
|
21
|
+
export interface DuplicateOverlapGroup {
|
|
22
|
+
key: string;
|
|
23
|
+
baseName: string;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
schedule: string;
|
|
26
|
+
loops: Array<Pick<Loop, "id" | "name" | "status" | "nextRunAt">>;
|
|
27
|
+
}
|
|
28
|
+
export interface DuplicateOverlapReport {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
generatedAt: string;
|
|
31
|
+
checked: number;
|
|
32
|
+
groups: DuplicateOverlapGroup[];
|
|
33
|
+
}
|
|
34
|
+
export interface ScriptBackedLoop {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
status: string;
|
|
38
|
+
cwd?: string;
|
|
39
|
+
command: string;
|
|
40
|
+
scriptMatches: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface ScriptInventoryReport {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
generatedAt: string;
|
|
45
|
+
checked: number;
|
|
46
|
+
scriptBacked: number;
|
|
47
|
+
loops: ScriptBackedLoop[];
|
|
48
|
+
}
|
|
49
|
+
export declare function buildNameHygieneReport(store: Store, opts?: {
|
|
50
|
+
apply?: boolean;
|
|
51
|
+
includeStopped?: boolean;
|
|
52
|
+
includeInactive?: boolean;
|
|
53
|
+
limit?: number;
|
|
54
|
+
}): NameHygieneReport;
|
|
55
|
+
export declare function buildDuplicateOverlapReport(store: Store, opts?: {
|
|
56
|
+
includeInactive?: boolean;
|
|
57
|
+
limit?: number;
|
|
58
|
+
}): DuplicateOverlapReport;
|
|
59
|
+
export declare function buildScriptInventoryReport(store: Store, opts?: {
|
|
60
|
+
scriptsDir?: string;
|
|
61
|
+
includeInactive?: boolean;
|
|
62
|
+
limit?: number;
|
|
63
|
+
}): 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;
|