@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/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
  }
@@ -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 disposable worktrees and explicit prompts that name allowed write scope.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
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",