@hasna/loops 0.3.25 → 0.3.27
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 +10 -0
- package/dist/cli/index.js +728 -95
- package/dist/daemon/index.js +50 -1
- package/dist/index.js +337 -24
- package/dist/lib/store.d.ts +1 -0
- package/dist/lib/store.js +49 -0
- package/dist/lib/templates.d.ts +16 -1
- package/dist/sdk/index.js +49 -0
- package/dist/types.d.ts +22 -0
- package/docs/USAGE.md +62 -3
- package/package.json +1 -1
package/dist/lib/store.js
CHANGED
|
@@ -416,6 +416,42 @@ function validateTarget(value, label) {
|
|
|
416
416
|
throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
|
|
417
417
|
}
|
|
418
418
|
}
|
|
419
|
+
if (value.worktree !== undefined) {
|
|
420
|
+
assertObject(value.worktree, `${label}.worktree`);
|
|
421
|
+
assertString(value.worktree.mode, `${label}.worktree.mode`);
|
|
422
|
+
const modes = ["auto", "required", "off", "main"];
|
|
423
|
+
if (!modes.includes(value.worktree.mode))
|
|
424
|
+
throw new Error(`${label}.worktree.mode must be one of ${modes.join(", ")}`);
|
|
425
|
+
if (typeof value.worktree.enabled !== "boolean")
|
|
426
|
+
throw new Error(`${label}.worktree.enabled must be a boolean`);
|
|
427
|
+
assertString(value.worktree.originalCwd, `${label}.worktree.originalCwd`);
|
|
428
|
+
assertString(value.worktree.cwd, `${label}.worktree.cwd`);
|
|
429
|
+
if (value.worktree.repoRoot !== undefined)
|
|
430
|
+
assertString(value.worktree.repoRoot, `${label}.worktree.repoRoot`);
|
|
431
|
+
if (value.worktree.root !== undefined)
|
|
432
|
+
assertString(value.worktree.root, `${label}.worktree.root`);
|
|
433
|
+
if (value.worktree.path !== undefined)
|
|
434
|
+
assertString(value.worktree.path, `${label}.worktree.path`);
|
|
435
|
+
if (value.worktree.branch !== undefined)
|
|
436
|
+
assertString(value.worktree.branch, `${label}.worktree.branch`);
|
|
437
|
+
if (value.worktree.reason !== undefined)
|
|
438
|
+
assertString(value.worktree.reason, `${label}.worktree.reason`);
|
|
439
|
+
}
|
|
440
|
+
if (value.routing !== undefined) {
|
|
441
|
+
assertObject(value.routing, `${label}.routing`);
|
|
442
|
+
if (value.routing.projectPath !== undefined)
|
|
443
|
+
assertString(value.routing.projectPath, `${label}.routing.projectPath`);
|
|
444
|
+
if (value.routing.projectGroup !== undefined)
|
|
445
|
+
assertString(value.routing.projectGroup, `${label}.routing.projectGroup`);
|
|
446
|
+
if (value.routing.taskId !== undefined)
|
|
447
|
+
assertString(value.routing.taskId, `${label}.routing.taskId`);
|
|
448
|
+
if (value.routing.eventId !== undefined)
|
|
449
|
+
assertString(value.routing.eventId, `${label}.routing.eventId`);
|
|
450
|
+
if (value.routing.eventType !== undefined)
|
|
451
|
+
assertString(value.routing.eventType, `${label}.routing.eventType`);
|
|
452
|
+
if (value.routing.eventSource !== undefined)
|
|
453
|
+
assertString(value.routing.eventSource, `${label}.routing.eventSource`);
|
|
454
|
+
}
|
|
419
455
|
return value;
|
|
420
456
|
}
|
|
421
457
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -2187,6 +2223,19 @@ class Store {
|
|
|
2187
2223
|
const row = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
|
|
2188
2224
|
return row ? rowToLease(row) : undefined;
|
|
2189
2225
|
}
|
|
2226
|
+
writeTransaction(fn) {
|
|
2227
|
+
this.db.exec("BEGIN IMMEDIATE;");
|
|
2228
|
+
try {
|
|
2229
|
+
const result = fn();
|
|
2230
|
+
this.db.exec("COMMIT;");
|
|
2231
|
+
return result;
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
try {
|
|
2234
|
+
this.db.exec("ROLLBACK;");
|
|
2235
|
+
} catch {}
|
|
2236
|
+
throw error;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2190
2239
|
close() {
|
|
2191
2240
|
this.db.close();
|
|
2192
2241
|
}
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, CreateWorkflowInput, LoopTemplateSummary } from "../types.js";
|
|
1
|
+
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, 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
4
|
export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
@@ -7,6 +7,8 @@ export interface TodosTaskWorkflowTemplateInput {
|
|
|
7
7
|
taskTitle?: string;
|
|
8
8
|
taskDescription?: string;
|
|
9
9
|
projectPath: string;
|
|
10
|
+
routeProjectPath?: string;
|
|
11
|
+
projectGroup?: string;
|
|
10
12
|
provider?: AgentProvider;
|
|
11
13
|
authProfile?: string;
|
|
12
14
|
authProfilePool?: string[];
|
|
@@ -21,6 +23,9 @@ export interface TodosTaskWorkflowTemplateInput {
|
|
|
21
23
|
agent?: string;
|
|
22
24
|
permissionMode?: AgentPermissionMode;
|
|
23
25
|
sandbox?: AgentSandbox;
|
|
26
|
+
worktreeMode?: AgentWorktreeMode;
|
|
27
|
+
worktreeRoot?: string;
|
|
28
|
+
worktreeBranchPrefix?: string;
|
|
24
29
|
eventId?: string;
|
|
25
30
|
eventType?: string;
|
|
26
31
|
}
|
|
@@ -32,6 +37,8 @@ export interface EventWorkflowTemplateInput {
|
|
|
32
37
|
eventMessage?: string;
|
|
33
38
|
eventJson: string;
|
|
34
39
|
projectPath: string;
|
|
40
|
+
routeProjectPath?: string;
|
|
41
|
+
projectGroup?: string;
|
|
35
42
|
provider?: AgentProvider;
|
|
36
43
|
authProfile?: string;
|
|
37
44
|
authProfilePool?: string[];
|
|
@@ -46,12 +53,17 @@ export interface EventWorkflowTemplateInput {
|
|
|
46
53
|
agent?: string;
|
|
47
54
|
permissionMode?: AgentPermissionMode;
|
|
48
55
|
sandbox?: AgentSandbox;
|
|
56
|
+
worktreeMode?: AgentWorktreeMode;
|
|
57
|
+
worktreeRoot?: string;
|
|
58
|
+
worktreeBranchPrefix?: string;
|
|
49
59
|
}
|
|
50
60
|
export interface BoundedAgentWorkflowTemplateInput {
|
|
51
61
|
name?: string;
|
|
52
62
|
objective: string;
|
|
53
63
|
prompt?: string;
|
|
54
64
|
projectPath: string;
|
|
65
|
+
routeProjectPath?: string;
|
|
66
|
+
projectGroup?: string;
|
|
55
67
|
provider?: AgentProvider;
|
|
56
68
|
authProfile?: string;
|
|
57
69
|
authProfilePool?: string[];
|
|
@@ -66,6 +78,9 @@ export interface BoundedAgentWorkflowTemplateInput {
|
|
|
66
78
|
agent?: string;
|
|
67
79
|
permissionMode?: AgentPermissionMode;
|
|
68
80
|
sandbox?: AgentSandbox;
|
|
81
|
+
worktreeMode?: AgentWorktreeMode;
|
|
82
|
+
worktreeRoot?: string;
|
|
83
|
+
worktreeBranchPrefix?: string;
|
|
69
84
|
timeoutMs?: number;
|
|
70
85
|
}
|
|
71
86
|
export declare function listLoopTemplates(): LoopTemplateSummary[];
|
package/dist/sdk/index.js
CHANGED
|
@@ -416,6 +416,42 @@ function validateTarget(value, label) {
|
|
|
416
416
|
throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
|
|
417
417
|
}
|
|
418
418
|
}
|
|
419
|
+
if (value.worktree !== undefined) {
|
|
420
|
+
assertObject(value.worktree, `${label}.worktree`);
|
|
421
|
+
assertString(value.worktree.mode, `${label}.worktree.mode`);
|
|
422
|
+
const modes = ["auto", "required", "off", "main"];
|
|
423
|
+
if (!modes.includes(value.worktree.mode))
|
|
424
|
+
throw new Error(`${label}.worktree.mode must be one of ${modes.join(", ")}`);
|
|
425
|
+
if (typeof value.worktree.enabled !== "boolean")
|
|
426
|
+
throw new Error(`${label}.worktree.enabled must be a boolean`);
|
|
427
|
+
assertString(value.worktree.originalCwd, `${label}.worktree.originalCwd`);
|
|
428
|
+
assertString(value.worktree.cwd, `${label}.worktree.cwd`);
|
|
429
|
+
if (value.worktree.repoRoot !== undefined)
|
|
430
|
+
assertString(value.worktree.repoRoot, `${label}.worktree.repoRoot`);
|
|
431
|
+
if (value.worktree.root !== undefined)
|
|
432
|
+
assertString(value.worktree.root, `${label}.worktree.root`);
|
|
433
|
+
if (value.worktree.path !== undefined)
|
|
434
|
+
assertString(value.worktree.path, `${label}.worktree.path`);
|
|
435
|
+
if (value.worktree.branch !== undefined)
|
|
436
|
+
assertString(value.worktree.branch, `${label}.worktree.branch`);
|
|
437
|
+
if (value.worktree.reason !== undefined)
|
|
438
|
+
assertString(value.worktree.reason, `${label}.worktree.reason`);
|
|
439
|
+
}
|
|
440
|
+
if (value.routing !== undefined) {
|
|
441
|
+
assertObject(value.routing, `${label}.routing`);
|
|
442
|
+
if (value.routing.projectPath !== undefined)
|
|
443
|
+
assertString(value.routing.projectPath, `${label}.routing.projectPath`);
|
|
444
|
+
if (value.routing.projectGroup !== undefined)
|
|
445
|
+
assertString(value.routing.projectGroup, `${label}.routing.projectGroup`);
|
|
446
|
+
if (value.routing.taskId !== undefined)
|
|
447
|
+
assertString(value.routing.taskId, `${label}.routing.taskId`);
|
|
448
|
+
if (value.routing.eventId !== undefined)
|
|
449
|
+
assertString(value.routing.eventId, `${label}.routing.eventId`);
|
|
450
|
+
if (value.routing.eventType !== undefined)
|
|
451
|
+
assertString(value.routing.eventType, `${label}.routing.eventType`);
|
|
452
|
+
if (value.routing.eventSource !== undefined)
|
|
453
|
+
assertString(value.routing.eventSource, `${label}.routing.eventSource`);
|
|
454
|
+
}
|
|
419
455
|
return value;
|
|
420
456
|
}
|
|
421
457
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -2187,6 +2223,19 @@ class Store {
|
|
|
2187
2223
|
const row = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
|
|
2188
2224
|
return row ? rowToLease(row) : undefined;
|
|
2189
2225
|
}
|
|
2226
|
+
writeTransaction(fn) {
|
|
2227
|
+
this.db.exec("BEGIN IMMEDIATE;");
|
|
2228
|
+
try {
|
|
2229
|
+
const result = fn();
|
|
2230
|
+
this.db.exec("COMMIT;");
|
|
2231
|
+
return result;
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
try {
|
|
2234
|
+
this.db.exec("ROLLBACK;");
|
|
2235
|
+
} catch {}
|
|
2236
|
+
throw error;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2190
2239
|
close() {
|
|
2191
2240
|
this.db.close();
|
|
2192
2241
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -63,6 +63,26 @@ export interface AgentAllowlistSpec {
|
|
|
63
63
|
commands?: string[];
|
|
64
64
|
enforcement?: "metadata_only";
|
|
65
65
|
}
|
|
66
|
+
export type AgentWorktreeMode = "auto" | "required" | "off" | "main";
|
|
67
|
+
export interface AgentWorktreeSpec {
|
|
68
|
+
mode: AgentWorktreeMode;
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
originalCwd: string;
|
|
71
|
+
cwd: string;
|
|
72
|
+
repoRoot?: string;
|
|
73
|
+
root?: string;
|
|
74
|
+
path?: string;
|
|
75
|
+
branch?: string;
|
|
76
|
+
reason?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface AgentRoutingSpec {
|
|
79
|
+
projectPath?: string;
|
|
80
|
+
projectGroup?: string;
|
|
81
|
+
taskId?: string;
|
|
82
|
+
eventId?: string;
|
|
83
|
+
eventType?: string;
|
|
84
|
+
eventSource?: string;
|
|
85
|
+
}
|
|
66
86
|
export interface AgentTarget {
|
|
67
87
|
type: "agent";
|
|
68
88
|
provider: AgentProvider;
|
|
@@ -78,6 +98,8 @@ export interface AgentTarget {
|
|
|
78
98
|
permissionMode?: AgentPermissionMode;
|
|
79
99
|
sandbox?: AgentSandbox;
|
|
80
100
|
allowlist?: AgentAllowlistSpec;
|
|
101
|
+
worktree?: AgentWorktreeSpec;
|
|
102
|
+
routing?: AgentRoutingSpec;
|
|
81
103
|
account?: AccountRef;
|
|
82
104
|
preflight?: RuntimePreflightPolicy;
|
|
83
105
|
}
|
package/docs/USAGE.md
CHANGED
|
@@ -243,6 +243,26 @@ loops templates render bounded-agent-worker-verifier \
|
|
|
243
243
|
--var sandbox=danger-full-access
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
+
Task/event agent templates default to `worktreeMode=auto`. When `projectPath`
|
|
247
|
+
is an existing git repository, OpenLoops inserts a `prepare-worktree` command
|
|
248
|
+
step before the worker and runs the worker/verifier from a deterministic
|
|
249
|
+
worktree under `~/.hasna/loops/worktrees/<repo>/<run>`. The generated agent
|
|
250
|
+
target includes worktree metadata (`mode`, `cwd`, `path`, `branch`,
|
|
251
|
+
`originalCwd`) so dry-runs and workflow inspection expose the exact checkout.
|
|
252
|
+
|
|
253
|
+
Use explicit main/default checkout mode only when the task truly requires it:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
loops templates render todos-task-worker-verifier \
|
|
257
|
+
--var taskId=<task-id> \
|
|
258
|
+
--var projectPath=/path/to/repo \
|
|
259
|
+
--var worktreeMode=main
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Use `worktreeMode=required` when non-worktree execution should fail fast, or
|
|
263
|
+
`worktreeMode=off` for non-git projects. `worktreeRoot` and
|
|
264
|
+
`worktreeBranchPrefix` can override the storage root and branch prefix.
|
|
265
|
+
|
|
246
266
|
For event-driven task automation, `loops events handle todos-task` reads a
|
|
247
267
|
Hasna event envelope from stdin or `HASNA_EVENT_JSON`, renders the template, and
|
|
248
268
|
schedules a deduped one-shot workflow loop:
|
|
@@ -252,7 +272,8 @@ cat task-created-event.json | loops events handle todos-task \
|
|
|
252
272
|
--provider codewith \
|
|
253
273
|
--auth-profile-pool account004,account005,account006 \
|
|
254
274
|
--permission-mode bypass \
|
|
255
|
-
--sandbox danger-full-access
|
|
275
|
+
--sandbox danger-full-access \
|
|
276
|
+
--worktree-mode auto
|
|
256
277
|
```
|
|
257
278
|
|
|
258
279
|
Task routing is explicit opt-in. The handler skips the event without creating a
|
|
@@ -263,6 +284,32 @@ approval-required, or `no-auto` tasks. This guard exists even when the upstream
|
|
|
263
284
|
`@hasna/events` webhook filter is misconfigured, so task existence alone is not
|
|
264
285
|
permission to execute agent work.
|
|
265
286
|
|
|
287
|
+
Use route throttles to avoid stampeding agents when a producer creates many
|
|
288
|
+
tasks at once:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
cat task-created-event.json | loops events handle todos-task \
|
|
292
|
+
--provider codewith \
|
|
293
|
+
--auth-profile-pool account004,account005,account006 \
|
|
294
|
+
--project-group oss \
|
|
295
|
+
--max-active-per-project 1 \
|
|
296
|
+
--max-active-per-project-group 4 \
|
|
297
|
+
--max-active 12
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The limits count active routed worker/verifier workflow loops once per workflow.
|
|
301
|
+
`--max-active-per-project` gates new work for the same project path,
|
|
302
|
+
`--max-active-per-project-group` shares a pool across related projects such as
|
|
303
|
+
`oss`, and `--max-active` is the global routed-workflow cap. Project matching
|
|
304
|
+
uses the canonical git top-level path when available, so repo subdirectories
|
|
305
|
+
share the same project cap. A throttled event is skipped with JSON evidence and
|
|
306
|
+
`queuedAtSource=true` instead of creating another worker loop; the source task
|
|
307
|
+
remains the durable queue item and should be replayed/drained later by the task
|
|
308
|
+
scheduler. Re-delivering the event later is safe because event handlers dedupe
|
|
309
|
+
by task/event id before rendering worktree plans or checking route limits. In
|
|
310
|
+
dry-run mode, throttle counts are not evaluated because opening the live loop
|
|
311
|
+
store can create or migrate the local database.
|
|
312
|
+
|
|
266
313
|
For other Hasna apps that expose `@hasna/events` webhooks, use the generic
|
|
267
314
|
handler:
|
|
268
315
|
|
|
@@ -281,7 +328,8 @@ verifier workflow, and the workflow updates todos with evidence. Use account
|
|
|
281
328
|
pools so worker and verifier steps do not burn the same profile; OpenLoops picks
|
|
282
329
|
deterministically and uses a different verifier profile when the pool has at
|
|
283
330
|
least two entries. Use `--dry-run` to inspect the rendered workflow and loop
|
|
284
|
-
input without storing anything
|
|
331
|
+
input without storing anything, including the worktree path and branch for
|
|
332
|
+
git-backed tasks.
|
|
285
333
|
|
|
286
334
|
## Transcript-Driven Loops
|
|
287
335
|
|
|
@@ -338,6 +386,14 @@ Use `--dry-run --json` first when testing a new automation path. Routed tasks
|
|
|
338
386
|
include the stable failure fingerprint, classification, loop id/name, and
|
|
339
387
|
`no_tmux_dispatch=true` metadata.
|
|
340
388
|
|
|
389
|
+
Use `--evidence-dir <dir>` when a deterministic loop needs a compact JSON
|
|
390
|
+
heartbeat/report on disk. Use `--auto-route` only on task lists that should feed
|
|
391
|
+
the task-created headless worker/verifier workflow; it adds the `auto:route`
|
|
392
|
+
tag and route metadata when the finding has a cwd or `--route-project-path` is
|
|
393
|
+
provided. Findings with no routeable working directory remain plain tasks and
|
|
394
|
+
record an `auto_route_skipped_reason`. Without `--auto-route`, route commands
|
|
395
|
+
only upsert deduped tasks and do not launch agents.
|
|
396
|
+
|
|
341
397
|
Failure classifications are: `rate_limit`, `auth`, `model_not_found`,
|
|
342
398
|
`context_length`, `schema_response_format`, `node_init`, `timeout`, `sigsegv`,
|
|
343
399
|
`skipped_previous_active`, and `unknown`.
|
|
@@ -441,4 +497,7 @@ The adapters intentionally use provider command surfaces instead of pretending e
|
|
|
441
497
|
- `--variant` is provider-specific reasoning/model effort. Claude maps it to `--effort`, Codewith/Codex map it to `model_reasoning_effort`, and OpenCode/AICopilot pass `--variant`.
|
|
442
498
|
- Daemon and scheduled runs prepend common user executable directories such as `~/.local/bin` and `~/.bun/bin` before resolving provider CLIs.
|
|
443
499
|
|
|
444
|
-
For production loops that can mutate repos, prefer
|
|
500
|
+
For production loops that can mutate repos, prefer the built-in
|
|
501
|
+
`worktreeMode=auto`/`required` path and explicit prompts that name allowed write
|
|
502
|
+
scope. Use `main` or `off` only for operations that intentionally need the
|
|
503
|
+
original checkout.
|
package/package.json
CHANGED