@hasna/loops 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ import type { Loop, LoopRun } from "../types.js";
2
+ import type { Store } from "./store.js";
3
+ export type RunFailureClassification = "rate_limit" | "auth" | "model_not_found" | "context_length" | "schema_response_format" | "node_init" | "timeout" | "sigsegv" | "skipped_previous_active" | "unknown";
4
+ export interface RunFailureSignal {
5
+ classification: RunFailureClassification;
6
+ fingerprint: string;
7
+ evidence: {
8
+ error?: string;
9
+ stdout?: string;
10
+ stderr?: string;
11
+ exitCode?: number;
12
+ };
13
+ }
14
+ export interface RecommendedTaskUpsert {
15
+ title: string;
16
+ description: string;
17
+ priority: "critical" | "high" | "medium" | "low";
18
+ tags: string[];
19
+ dedupeKey: string;
20
+ search: {
21
+ query: string;
22
+ };
23
+ compatibilityFallback: {
24
+ search: string[];
25
+ add: string[];
26
+ comment: string[];
27
+ };
28
+ futureNativeUpsert: {
29
+ command: string;
30
+ fields: Record<string, string | string[]>;
31
+ };
32
+ }
33
+ export interface LoopExpectationResult {
34
+ loop: Pick<Loop, "id" | "name" | "status" | "nextRunAt">;
35
+ ok: boolean;
36
+ check: {
37
+ id: "latest-run-succeeded";
38
+ status: "pass" | "fail" | "warn";
39
+ message: string;
40
+ };
41
+ latestRun?: LoopRun;
42
+ failure?: RunFailureSignal;
43
+ route: {
44
+ source: "openloops";
45
+ kind: "loop_expectation";
46
+ loopId: string;
47
+ loopName: string;
48
+ cwd?: string;
49
+ provider?: string;
50
+ };
51
+ recommendedTask?: RecommendedTaskUpsert;
52
+ }
53
+ export interface LoopsHealthReport {
54
+ ok: boolean;
55
+ generatedAt: string;
56
+ summary: {
57
+ loops: number;
58
+ healthy: number;
59
+ unhealthy: number;
60
+ warnings: number;
61
+ };
62
+ classifications: Record<RunFailureClassification, number>;
63
+ expectations: LoopExpectationResult[];
64
+ }
65
+ export declare function classifyRunFailure(run: LoopRun): RunFailureSignal | undefined;
66
+ export declare function expectationForLoop(store: Store, loop: Loop): LoopExpectationResult;
67
+ export declare function buildHealthReport(store: Store, opts?: {
68
+ includeArchived?: boolean;
69
+ limit?: number;
70
+ }): LoopsHealthReport;
@@ -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;
@@ -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
@@ -326,6 +326,17 @@ function optionalPositiveInteger(value, label) {
326
326
  throw new Error(`${label} must be a positive integer`);
327
327
  return value;
328
328
  }
329
+ function optionalStringArray(value, label) {
330
+ if (value === undefined)
331
+ return;
332
+ if (!Array.isArray(value))
333
+ throw new Error(`${label} must be an array`);
334
+ const values = value.map((entry, index) => {
335
+ assertString(entry, `${label}[${index}]`);
336
+ return entry.trim();
337
+ }).filter(Boolean);
338
+ return values.length ? values : undefined;
339
+ }
329
340
  function normalizeGoalSpec(value, label = "goal") {
330
341
  if (value === undefined)
331
342
  return;
@@ -397,6 +408,14 @@ function validateTarget(value, label) {
397
408
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
398
409
  }
399
410
  }
411
+ if (value.allowlist !== undefined) {
412
+ assertObject(value.allowlist, `${label}.allowlist`);
413
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
414
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
415
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
416
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
417
+ }
418
+ }
400
419
  return value;
401
420
  }
402
421
  throw new Error(`${label}.type must be command or agent`);
@@ -1031,6 +1050,30 @@ class Store {
1031
1050
  throw new Error(`loop not found after update: ${id}`);
1032
1051
  return after;
1033
1052
  }
1053
+ renameLoop(id, name, opts = {}) {
1054
+ const current = this.getLoop(id);
1055
+ if (!current)
1056
+ throw new Error(`loop not found: ${id}`);
1057
+ const trimmed = name.trim();
1058
+ if (!trimmed)
1059
+ throw new Error("loop name must not be empty");
1060
+ const updated = (opts.now ?? new Date).toISOString();
1061
+ this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
1062
+ WHERE id=$id
1063
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1064
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1065
+ ))`).run({
1066
+ $id: id,
1067
+ $name: trimmed,
1068
+ $updated: updated,
1069
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1070
+ $now: updated
1071
+ });
1072
+ const after = this.getLoop(id);
1073
+ if (!after)
1074
+ throw new Error(`loop not found after rename: ${id}`);
1075
+ return after;
1076
+ }
1034
1077
  archiveLoop(idOrName) {
1035
1078
  const loop = this.requireLoop(idOrName);
1036
1079
  if (loop.archivedAt)
@@ -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
@@ -326,6 +326,17 @@ function optionalPositiveInteger(value, label) {
326
326
  throw new Error(`${label} must be a positive integer`);
327
327
  return value;
328
328
  }
329
+ function optionalStringArray(value, label) {
330
+ if (value === undefined)
331
+ return;
332
+ if (!Array.isArray(value))
333
+ throw new Error(`${label} must be an array`);
334
+ const values = value.map((entry, index) => {
335
+ assertString(entry, `${label}[${index}]`);
336
+ return entry.trim();
337
+ }).filter(Boolean);
338
+ return values.length ? values : undefined;
339
+ }
329
340
  function normalizeGoalSpec(value, label = "goal") {
330
341
  if (value === undefined)
331
342
  return;
@@ -397,6 +408,14 @@ function validateTarget(value, label) {
397
408
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
398
409
  }
399
410
  }
411
+ if (value.allowlist !== undefined) {
412
+ assertObject(value.allowlist, `${label}.allowlist`);
413
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
414
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
415
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
416
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
417
+ }
418
+ }
400
419
  return value;
401
420
  }
402
421
  throw new Error(`${label}.type must be command or agent`);
@@ -1031,6 +1050,30 @@ class Store {
1031
1050
  throw new Error(`loop not found after update: ${id}`);
1032
1051
  return after;
1033
1052
  }
1053
+ renameLoop(id, name, opts = {}) {
1054
+ const current = this.getLoop(id);
1055
+ if (!current)
1056
+ throw new Error(`loop not found: ${id}`);
1057
+ const trimmed = name.trim();
1058
+ if (!trimmed)
1059
+ throw new Error("loop name must not be empty");
1060
+ const updated = (opts.now ?? new Date).toISOString();
1061
+ this.db.query(`UPDATE loops SET name=$name, updated_at=$updated
1062
+ WHERE id=$id
1063
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1064
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1065
+ ))`).run({
1066
+ $id: id,
1067
+ $name: trimmed,
1068
+ $updated: updated,
1069
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1070
+ $now: updated
1071
+ });
1072
+ const after = this.getLoop(id);
1073
+ if (!after)
1074
+ throw new Error(`loop not found after rename: ${id}`);
1075
+ return after;
1076
+ }
1034
1077
  archiveLoop(idOrName) {
1035
1078
  const loop = this.requireLoop(idOrName);
1036
1079
  if (loop.archivedAt)
@@ -2455,6 +2498,16 @@ function metadataEnv(metadata) {
2455
2498
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2456
2499
  return env;
2457
2500
  }
2501
+ function allowlistEnv(allowlist) {
2502
+ const env = {};
2503
+ if (allowlist?.tools?.length)
2504
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2505
+ if (allowlist?.commands?.length)
2506
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2507
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2508
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2509
+ return env;
2510
+ }
2458
2511
  function providerCommand(provider) {
2459
2512
  switch (provider) {
2460
2513
  case "claude":
@@ -2662,7 +2715,8 @@ function commandSpec(target) {
2662
2715
  account: agentTarget.account,
2663
2716
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2664
2717
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2665
- stdin: agentTarget.prompt
2718
+ stdin: agentTarget.prompt,
2719
+ allowlist: agentTarget.allowlist
2666
2720
  };
2667
2721
  }
2668
2722
  function executionEnv(spec, metadata, opts) {
@@ -2674,6 +2728,7 @@ function executionEnv(spec, metadata, opts) {
2674
2728
  Object.assign(env, accountEnv);
2675
2729
  }
2676
2730
  Object.assign(env, spec.env ?? {});
2731
+ Object.assign(env, allowlistEnv(spec.allowlist));
2677
2732
  env.PATH = normalizeExecutionPath(env);
2678
2733
  Object.assign(env, metadataEnv(metadata));
2679
2734
  return env;
@@ -2712,6 +2767,9 @@ function remoteBootstrapLines(spec, metadata) {
2712
2767
  continue;
2713
2768
  lines.push(`export ${key}=${shellQuote(value)}`);
2714
2769
  }
2770
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2771
+ lines.push(`export ${key}=${shellQuote(value)}`);
2772
+ }
2715
2773
  return lines;
2716
2774
  }
2717
2775
  function remoteScript(spec, metadata) {
package/dist/types.d.ts CHANGED
@@ -54,6 +54,11 @@ export type AgentProvider = "claude" | "cursor" | "codewith" | "aicopilot" | "op
54
54
  export type AgentConfigIsolation = "safe" | "none";
55
55
  export type AgentPermissionMode = "default" | "plan" | "auto" | "bypass";
56
56
  export type AgentSandbox = "read-only" | "workspace-write" | "danger-full-access" | "enabled" | "disabled";
57
+ export interface AgentAllowlistSpec {
58
+ tools?: string[];
59
+ commands?: string[];
60
+ enforcement?: "metadata_only";
61
+ }
57
62
  export interface AgentTarget {
58
63
  type: "agent";
59
64
  provider: AgentProvider;
@@ -68,6 +73,7 @@ export interface AgentTarget {
68
73
  configIsolation?: AgentConfigIsolation;
69
74
  permissionMode?: AgentPermissionMode;
70
75
  sandbox?: AgentSandbox;
76
+ allowlist?: AgentAllowlistSpec;
71
77
  account?: AccountRef;
72
78
  }
73
79
  export interface WorkflowTarget {
package/docs/USAGE.md CHANGED
@@ -94,6 +94,23 @@ loops create agent supply-chain-watch \
94
94
  --prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."
95
95
  ```
96
96
 
97
+ Agent loops can also carry advisory per-session allowlist metadata:
98
+
99
+ ```bash
100
+ loops create agent repo-check \
101
+ --provider codewith \
102
+ --every 15m \
103
+ --cwd /path/to/repo \
104
+ --prompt "Check the repo and report concrete failures." \
105
+ --allow-tool functions.exec_command \
106
+ --allow-command git,bun
107
+ ```
108
+
109
+ These fields are stored on the loop target and exposed to the run environment
110
+ as `LOOPS_AGENT_ALLOWED_TOOLS`, `LOOPS_AGENT_ALLOWED_COMMANDS`, and
111
+ `LOOPS_AGENT_ALLOWLIST_ENFORCEMENT=metadata_only`. They are not enforced by
112
+ OpenLoops yet; provider-native enforcement will be added separately.
113
+
97
114
  For `codewith` and `aicopilot` account isolation, register matching OpenAccounts tools first if they are not built in on the machine:
98
115
 
99
116
  ```bash
@@ -167,7 +184,8 @@ Use `shell: true` only when you intentionally want shell parsing:
167
184
  Built-in templates turn common orchestration flows into reusable workflow JSON.
168
185
  `todos-task-worker-verifier` performs one todos task and then verifies it.
169
186
  `event-worker-verifier` handles any Hasna event envelope and then verifies the
170
- 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.
171
189
 
172
190
  ```bash
173
191
  loops templates list
@@ -187,6 +205,12 @@ loops templates render event-worker-verifier \
187
205
  --var eventSource=knowledge \
188
206
  --var eventJson='{"id":"<event-id>"}' \
189
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
190
214
  ```
191
215
 
192
216
  For event-driven task automation, `loops events handle todos-task` reads a
@@ -201,6 +225,14 @@ cat task-created-event.json | loops events handle todos-task \
201
225
  --sandbox danger-full-access
202
226
  ```
203
227
 
228
+ Task routing is explicit opt-in. The handler skips the event without creating a
229
+ workflow unless the event data or metadata has `route_enabled=true`,
230
+ `automation.allowed=true`, or a task tag containing `auto:route`. It also skips
231
+ blocked, completed/done, cancelled/canceled, failed, archived, manual,
232
+ approval-required, or `no-auto` tasks. This guard exists even when the upstream
233
+ `@hasna/events` webhook filter is misconfigured, so task existence alone is not
234
+ permission to execute agent work.
235
+
204
236
  For other Hasna apps that expose `@hasna/events` webhooks, use the generic
205
237
  handler:
206
238
 
@@ -250,6 +282,54 @@ loops run-now <id-or-name>
250
282
 
251
283
  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.
252
284
 
285
+ ## Health And Expectations
286
+
287
+ `loops health --json` summarizes the latest run for each loop and classifies
288
+ agent-run failures for default-loop SLOs:
289
+
290
+ ```bash
291
+ loops health --json
292
+ loops expectations <loop-id-or-name> --json
293
+ ```
294
+
295
+ The JSON contains the expectation result, bounded error/stdout/stderr evidence,
296
+ a stable failure fingerprint, route metadata, and recommended task fields.
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.
310
+
311
+ Failure classifications are: `rate_limit`, `auth`, `model_not_found`,
312
+ `context_length`, `schema_response_format`, `node_init`, `timeout`, `sigsegv`,
313
+ `skipped_previous_active`, and `unknown`.
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
+
253
333
  Archive loops when retiring old automation but preserving history:
254
334
 
255
335
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",