@hasna/loops 0.3.26 → 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/cli/index.js CHANGED
@@ -418,6 +418,42 @@ function validateTarget(value, label) {
418
418
  throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
419
419
  }
420
420
  }
421
+ if (value.worktree !== undefined) {
422
+ assertObject(value.worktree, `${label}.worktree`);
423
+ assertString(value.worktree.mode, `${label}.worktree.mode`);
424
+ const modes = ["auto", "required", "off", "main"];
425
+ if (!modes.includes(value.worktree.mode))
426
+ throw new Error(`${label}.worktree.mode must be one of ${modes.join(", ")}`);
427
+ if (typeof value.worktree.enabled !== "boolean")
428
+ throw new Error(`${label}.worktree.enabled must be a boolean`);
429
+ assertString(value.worktree.originalCwd, `${label}.worktree.originalCwd`);
430
+ assertString(value.worktree.cwd, `${label}.worktree.cwd`);
431
+ if (value.worktree.repoRoot !== undefined)
432
+ assertString(value.worktree.repoRoot, `${label}.worktree.repoRoot`);
433
+ if (value.worktree.root !== undefined)
434
+ assertString(value.worktree.root, `${label}.worktree.root`);
435
+ if (value.worktree.path !== undefined)
436
+ assertString(value.worktree.path, `${label}.worktree.path`);
437
+ if (value.worktree.branch !== undefined)
438
+ assertString(value.worktree.branch, `${label}.worktree.branch`);
439
+ if (value.worktree.reason !== undefined)
440
+ assertString(value.worktree.reason, `${label}.worktree.reason`);
441
+ }
442
+ if (value.routing !== undefined) {
443
+ assertObject(value.routing, `${label}.routing`);
444
+ if (value.routing.projectPath !== undefined)
445
+ assertString(value.routing.projectPath, `${label}.routing.projectPath`);
446
+ if (value.routing.projectGroup !== undefined)
447
+ assertString(value.routing.projectGroup, `${label}.routing.projectGroup`);
448
+ if (value.routing.taskId !== undefined)
449
+ assertString(value.routing.taskId, `${label}.routing.taskId`);
450
+ if (value.routing.eventId !== undefined)
451
+ assertString(value.routing.eventId, `${label}.routing.eventId`);
452
+ if (value.routing.eventType !== undefined)
453
+ assertString(value.routing.eventType, `${label}.routing.eventType`);
454
+ if (value.routing.eventSource !== undefined)
455
+ assertString(value.routing.eventSource, `${label}.routing.eventSource`);
456
+ }
421
457
  return value;
422
458
  }
423
459
  throw new Error(`${label}.type must be command or agent`);
@@ -2189,6 +2225,19 @@ class Store {
2189
2225
  const row = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
2190
2226
  return row ? rowToLease(row) : undefined;
2191
2227
  }
2228
+ writeTransaction(fn) {
2229
+ this.db.exec("BEGIN IMMEDIATE;");
2230
+ try {
2231
+ const result = fn();
2232
+ this.db.exec("COMMIT;");
2233
+ return result;
2234
+ } catch (error) {
2235
+ try {
2236
+ this.db.exec("ROLLBACK;");
2237
+ } catch {}
2238
+ throw error;
2239
+ }
2240
+ }
2192
2241
  close() {
2193
2242
  this.db.close();
2194
2243
  }
@@ -2196,9 +2245,9 @@ class Store {
2196
2245
 
2197
2246
  // src/cli/index.ts
2198
2247
  import { createHash as createHash2, randomUUID } from "crypto";
2199
- import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2248
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, realpathSync, writeFileSync as writeFileSync3 } from "fs";
2200
2249
  import { spawnSync as spawnSync5 } from "child_process";
2201
- import { join as join3 } from "path";
2250
+ import { join as join4, resolve as resolve2 } from "path";
2202
2251
  import { Database as Database2 } from "bun:sqlite";
2203
2252
  import { Command } from "commander";
2204
2253
 
@@ -5204,7 +5253,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5204
5253
  // package.json
5205
5254
  var package_default = {
5206
5255
  name: "@hasna/loops",
5207
- version: "0.3.26",
5256
+ version: "0.3.27",
5208
5257
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5209
5258
  type: "module",
5210
5259
  main: "dist/index.js",
@@ -5293,6 +5342,10 @@ function packageVersion() {
5293
5342
  }
5294
5343
 
5295
5344
  // src/lib/templates.ts
5345
+ import { execFileSync } from "child_process";
5346
+ import { existsSync as existsSync3 } from "fs";
5347
+ import { homedir as homedir3 } from "os";
5348
+ import { basename as basename2, isAbsolute, join as join3, relative, resolve } from "path";
5296
5349
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
5297
5350
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5298
5351
  var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
@@ -5306,6 +5359,8 @@ var TEMPLATE_SUMMARIES = [
5306
5359
  { name: "taskId", required: true, description: "Todos task id to execute." },
5307
5360
  { name: "taskTitle", description: "Human-readable task title." },
5308
5361
  { name: "projectPath", required: true, description: "Repository or project working directory." },
5362
+ { name: "routeProjectPath", description: "Canonical project path used for scheduler concurrency limits." },
5363
+ { name: "projectGroup", description: "Optional project group used for scheduler concurrency limits." },
5309
5364
  { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
5310
5365
  { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
5311
5366
  { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
@@ -5315,7 +5370,10 @@ var TEMPLATE_SUMMARIES = [
5315
5370
  { name: "model", description: "Provider model." },
5316
5371
  { name: "variant", description: "Provider reasoning/model effort variant." },
5317
5372
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5318
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
5373
+ { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5374
+ { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5375
+ { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5376
+ { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
5319
5377
  ]
5320
5378
  },
5321
5379
  {
@@ -5329,6 +5387,8 @@ var TEMPLATE_SUMMARIES = [
5329
5387
  { name: "eventSource", required: true, description: "Hasna event source." },
5330
5388
  { name: "eventJson", required: true, description: "Full event envelope JSON." },
5331
5389
  { name: "projectPath", required: true, description: "Repository or project working directory." },
5390
+ { name: "routeProjectPath", description: "Canonical project path used for scheduler concurrency limits." },
5391
+ { name: "projectGroup", description: "Optional project group used for scheduler concurrency limits." },
5332
5392
  { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
5333
5393
  { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
5334
5394
  { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
@@ -5338,7 +5398,10 @@ var TEMPLATE_SUMMARIES = [
5338
5398
  { name: "model", description: "Provider model." },
5339
5399
  { name: "variant", description: "Provider reasoning/model effort variant." },
5340
5400
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5341
- { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
5401
+ { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5402
+ { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5403
+ { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5404
+ { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
5342
5405
  ]
5343
5406
  },
5344
5407
  {
@@ -5350,6 +5413,8 @@ var TEMPLATE_SUMMARIES = [
5350
5413
  { name: "objective", required: true, description: "Narrow goal-mode objective for the worker." },
5351
5414
  { name: "prompt", description: "Optional extra worker prompt details." },
5352
5415
  { name: "projectPath", required: true, description: "Repository or project working directory." },
5416
+ { name: "routeProjectPath", description: "Canonical project path used for scheduler concurrency limits." },
5417
+ { name: "projectGroup", description: "Optional project group used for scheduler concurrency limits." },
5353
5418
  { name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
5354
5419
  { name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
5355
5420
  { name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
@@ -5360,6 +5425,9 @@ var TEMPLATE_SUMMARIES = [
5360
5425
  { name: "variant", description: "Provider reasoning/model effort variant." },
5361
5426
  { name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
5362
5427
  { name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
5428
+ { name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
5429
+ { name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
5430
+ { name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
5363
5431
  { name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
5364
5432
  ]
5365
5433
  }
@@ -5401,6 +5469,183 @@ function accountForRole(input, role, seed) {
5401
5469
  return input.verifierAccount;
5402
5470
  return rolePoolValue(input.accountPool, seed, role) ?? input.account;
5403
5471
  }
5472
+ function slugSegment(value, fallback = "item") {
5473
+ const slug = (value ?? "").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
5474
+ return slug || fallback;
5475
+ }
5476
+ function stableHex(seed) {
5477
+ return stableIndex(seed, 4294967295).toString(16).padStart(8, "0");
5478
+ }
5479
+ function shellQuote2(value) {
5480
+ return `'${value.replace(/'/g, `'\\''`)}'`;
5481
+ }
5482
+ function normalizeWorktreeMode(mode) {
5483
+ const value = mode ?? "auto";
5484
+ if (!["auto", "required", "off", "main"].includes(value)) {
5485
+ throw new Error(`worktreeMode must be one of auto, required, off, or main`);
5486
+ }
5487
+ return value;
5488
+ }
5489
+ function defaultWorktreeRoot(root) {
5490
+ if (root?.trim()) {
5491
+ const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
5492
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
5493
+ }
5494
+ return join3(homedir3(), ".hasna", "loops", "worktrees");
5495
+ }
5496
+ function gitRootFor(path) {
5497
+ if (!existsSync3(path))
5498
+ return;
5499
+ try {
5500
+ return execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
5501
+ encoding: "utf8",
5502
+ stdio: ["ignore", "pipe", "ignore"]
5503
+ }).trim();
5504
+ } catch {
5505
+ return;
5506
+ }
5507
+ }
5508
+ function prepareWorktreeCommand(plan) {
5509
+ const repo = shellQuote2(plan.repoRoot);
5510
+ const path = shellQuote2(plan.path);
5511
+ const branch = shellQuote2(plan.branch);
5512
+ return [
5513
+ "set -euo pipefail",
5514
+ `repo=${repo}`,
5515
+ `path=${path}`,
5516
+ `branch=${branch}`,
5517
+ 'resolve_path() { cd "$1" && pwd -P; }',
5518
+ "git_common_dir() {",
5519
+ ' local base="$1"',
5520
+ " local common",
5521
+ ' common="$(git -C "$base" rev-parse --git-common-dir)"',
5522
+ ' case "$common" in',
5523
+ ' /*) printf "%s\\n" "$common" ;;',
5524
+ ' *) (cd "$base" && cd "$common" && pwd -P) ;;',
5525
+ " esac",
5526
+ "}",
5527
+ 'mkdir -p "$(dirname "$path")"',
5528
+ 'if [ -e "$path" ]; then',
5529
+ ' if [ -L "$path" ]; then',
5530
+ ' printf "refusing symlinked worktree path %s\\n" "$path" >&2',
5531
+ " exit 1",
5532
+ " fi",
5533
+ ' if git -C "$path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then',
5534
+ ' expected_common="$(git_common_dir "$repo")"',
5535
+ ' actual_common="$(git_common_dir "$path")"',
5536
+ ' if [ "$actual_common" != "$expected_common" ]; then',
5537
+ ' printf "existing worktree %s belongs to different git common dir\\n" "$path" >&2',
5538
+ ' printf "expected %s got %s\\n" "$expected_common" "$actual_common" >&2',
5539
+ " exit 1",
5540
+ " fi",
5541
+ ' actual_top="$(git -C "$path" rev-parse --show-toplevel)"',
5542
+ ' actual_top="$(resolve_path "$actual_top")"',
5543
+ ' expected_top="$(resolve_path "$path")"',
5544
+ ' if [ "$actual_top" != "$expected_top" ]; then',
5545
+ ' printf "existing worktree top-level mismatch for %s: %s\\n" "$path" "$actual_top" >&2',
5546
+ " exit 1",
5547
+ " fi",
5548
+ ' actual_branch="$(git -C "$path" branch --show-current)"',
5549
+ ' if [ "$actual_branch" != "$branch" ]; then',
5550
+ ' printf "existing worktree %s is on branch %s, expected %s\\n" "$path" "$actual_branch" "$branch" >&2',
5551
+ " exit 1",
5552
+ " fi",
5553
+ ' printf "existing worktree %s branch %s\\n" "$path" "$branch"',
5554
+ " exit 0",
5555
+ " fi",
5556
+ ' printf "refusing to overwrite non-git path: %s\\n" "$path" >&2',
5557
+ " exit 1",
5558
+ "fi",
5559
+ 'git -C "$repo" rev-parse --is-inside-work-tree >/dev/null',
5560
+ 'if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
5561
+ ' git -C "$repo" worktree add "$path" "$branch"',
5562
+ "else",
5563
+ ' git -C "$repo" worktree add -b "$branch" "$path" HEAD',
5564
+ "fi",
5565
+ 'printf "prepared worktree %s branch %s\\n" "$path" "$branch"'
5566
+ ].join(`
5567
+ `);
5568
+ }
5569
+ function worktreePlan(input, seed) {
5570
+ const mode = normalizeWorktreeMode(input.worktreeMode);
5571
+ const originalCwd = input.projectPath;
5572
+ if (mode === "off" || mode === "main") {
5573
+ return {
5574
+ mode,
5575
+ enabled: false,
5576
+ originalCwd,
5577
+ cwd: originalCwd,
5578
+ reason: mode === "main" ? "explicit main/default checkout mode" : "worktree mode disabled"
5579
+ };
5580
+ }
5581
+ const repoRoot = gitRootFor(originalCwd);
5582
+ if (!repoRoot) {
5583
+ if (mode === "required") {
5584
+ throw new Error(`worktreeMode=required but projectPath is not an existing git repository: ${originalCwd}`);
5585
+ }
5586
+ return {
5587
+ mode,
5588
+ enabled: false,
5589
+ originalCwd,
5590
+ cwd: originalCwd,
5591
+ reason: "projectPath is not an existing git repository"
5592
+ };
5593
+ }
5594
+ const root = defaultWorktreeRoot(input.worktreeRoot);
5595
+ const repoSlug = slugSegment(basename2(repoRoot), "repo");
5596
+ const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
5597
+ const worktreePath = join3(root, repoSlug, seedSlug);
5598
+ const relativeCwd = relative(repoRoot, originalCwd);
5599
+ const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join3(worktreePath, relativeCwd) : worktreePath;
5600
+ const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
5601
+ const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
5602
+ const prepareStep = {
5603
+ id: "prepare-worktree",
5604
+ name: "Prepare Worktree",
5605
+ description: "Create or reuse the isolated OpenLoops git worktree for this workflow run.",
5606
+ target: {
5607
+ type: "command",
5608
+ command: "bash",
5609
+ args: ["-lc", prepareWorktreeCommand({ repoRoot, path: worktreePath, branch })],
5610
+ cwd: repoRoot,
5611
+ timeoutMs: 5 * 60000
5612
+ },
5613
+ timeoutMs: 5 * 60000
5614
+ };
5615
+ return {
5616
+ mode,
5617
+ enabled: true,
5618
+ originalCwd,
5619
+ cwd,
5620
+ repoRoot,
5621
+ root,
5622
+ path: worktreePath,
5623
+ branch,
5624
+ prepareStep
5625
+ };
5626
+ }
5627
+ function worktreePrompt(plan) {
5628
+ if (plan.enabled) {
5629
+ return [
5630
+ "OpenLoops worktree policy:",
5631
+ "- Use the isolated git worktree as the only writeable repository checkout for this task/event.",
5632
+ `- Worktree cwd: ${plan.cwd}`,
5633
+ `- Worktree root: ${plan.path}`,
5634
+ `- Branch: ${plan.branch}`,
5635
+ `- Original checkout: ${plan.originalCwd}`,
5636
+ "- Do not mutate the original checkout/main branch except for read-only inspection.",
5637
+ "- Preserve unrelated changes in both the original checkout and this worktree."
5638
+ ].join(`
5639
+ `);
5640
+ }
5641
+ return [
5642
+ "OpenLoops worktree policy:",
5643
+ `- Worktree mode ${plan.mode} did not select an isolated worktree: ${plan.reason ?? "not enabled"}.`,
5644
+ `- Cwd: ${plan.cwd}`,
5645
+ "- Do not create ad hoc worktrees unless the task itself explicitly requires one."
5646
+ ].join(`
5647
+ `);
5648
+ }
5404
5649
  function assertNativeAuthProfileSupport(input, provider) {
5405
5650
  if (provider === "codewith")
5406
5651
  return;
@@ -5409,7 +5654,7 @@ function assertNativeAuthProfileSupport(input, provider) {
5409
5654
  return;
5410
5655
  throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
5411
5656
  }
5412
- function agentTarget(input, prompt, role, seed) {
5657
+ function agentTarget(input, prompt, role, seed, plan) {
5413
5658
  const provider = input.provider ?? "codewith";
5414
5659
  assertNativeAuthProfileSupport(input, provider);
5415
5660
  const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
@@ -5417,7 +5662,7 @@ function agentTarget(input, prompt, role, seed) {
5417
5662
  type: "agent",
5418
5663
  provider,
5419
5664
  prompt,
5420
- cwd: input.projectPath,
5665
+ cwd: plan.cwd,
5421
5666
  model: input.model,
5422
5667
  variant: input.variant,
5423
5668
  agent: input.agent,
@@ -5425,10 +5670,33 @@ function agentTarget(input, prompt, role, seed) {
5425
5670
  configIsolation: "safe",
5426
5671
  permissionMode: input.permissionMode ?? "bypass",
5427
5672
  sandbox,
5673
+ worktree: {
5674
+ mode: plan.mode,
5675
+ enabled: plan.enabled,
5676
+ originalCwd: plan.originalCwd,
5677
+ cwd: plan.cwd,
5678
+ repoRoot: plan.repoRoot,
5679
+ root: plan.root,
5680
+ path: plan.path,
5681
+ branch: plan.branch,
5682
+ reason: plan.reason
5683
+ },
5684
+ routing: {
5685
+ projectPath: input.routeProjectPath ?? input.projectPath,
5686
+ ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
5687
+ },
5428
5688
  account: accountForRole(input, role, seed),
5429
5689
  timeoutMs: 45 * 60000
5430
5690
  };
5431
5691
  }
5692
+ function workflowStepsWithWorktree(plan, steps) {
5693
+ if (!plan.prepareStep)
5694
+ return steps;
5695
+ return [
5696
+ plan.prepareStep,
5697
+ ...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
5698
+ ];
5699
+ }
5432
5700
  function listLoopTemplates() {
5433
5701
  return TEMPLATE_SUMMARIES.map((template) => structuredClone(template));
5434
5702
  }
@@ -5440,18 +5708,30 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5440
5708
  throw new Error("taskId is required");
5441
5709
  if (!input.projectPath?.trim())
5442
5710
  throw new Error("projectPath is required");
5711
+ const plan = worktreePlan(input, input.taskId);
5443
5712
  const taskContext = {
5444
5713
  taskId: input.taskId,
5445
5714
  taskTitle: input.taskTitle,
5446
5715
  taskDescription: input.taskDescription,
5447
5716
  eventId: input.eventId,
5448
5717
  eventType: input.eventType,
5449
- projectPath: input.projectPath
5718
+ projectPath: input.projectPath,
5719
+ routeProjectPath: input.routeProjectPath,
5720
+ projectGroup: input.projectGroup,
5721
+ worktree: {
5722
+ mode: plan.mode,
5723
+ enabled: plan.enabled,
5724
+ cwd: plan.cwd,
5725
+ path: plan.path,
5726
+ branch: plan.branch,
5727
+ reason: plan.reason
5728
+ }
5450
5729
  };
5451
5730
  const workerPrompt = [
5452
5731
  `/goal Complete todos task ${input.taskId} in ${input.projectPath}.`,
5453
5732
  "",
5454
5733
  "You are the worker agent for a task-triggered OpenLoops workflow.",
5734
+ worktreePrompt(plan),
5455
5735
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
5456
5736
  "Claim/start the task if appropriate, inspect the repository/project state, implement only the task scope, run focused validation, preserve unrelated user changes, and update the task with comments, evidence, changed files, commits, and blockers.",
5457
5737
  "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.",
@@ -5464,6 +5744,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5464
5744
  `/goal Verify todos task ${input.taskId} after the worker step.`,
5465
5745
  "",
5466
5746
  "You are the verifier agent for a task-triggered OpenLoops workflow.",
5747
+ worktreePrompt(plan),
5467
5748
  "Use fresh context. Inspect the task, repository state, commits, tests, and worker evidence. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
5468
5749
  "If the work is valid, record verification evidence in todos and mark/leave the task in the correct completed state according to the todos CLI. If it is not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
5469
5750
  "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.",
@@ -5476,12 +5757,12 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5476
5757
  name: `todos-task-${input.taskId.slice(0, 8)}-worker-verifier`,
5477
5758
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
5478
5759
  version: 1,
5479
- steps: [
5760
+ steps: workflowStepsWithWorktree(plan, [
5480
5761
  {
5481
5762
  id: "worker",
5482
5763
  name: "Worker",
5483
5764
  description: "Implement the todos task and record evidence.",
5484
- target: agentTarget(input, workerPrompt, "worker", input.taskId),
5765
+ target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
5485
5766
  timeoutMs: 45 * 60000
5486
5767
  },
5487
5768
  {
@@ -5489,10 +5770,10 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5489
5770
  name: "Verifier",
5490
5771
  description: "Adversarially verify worker output and update todos.",
5491
5772
  dependsOn: ["worker"],
5492
- target: agentTarget(input, verifierPrompt, "verifier", input.taskId),
5773
+ target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
5493
5774
  timeoutMs: 30 * 60000
5494
5775
  }
5495
- ]
5776
+ ])
5496
5777
  };
5497
5778
  }
5498
5779
  function renderEventWorkerVerifierWorkflow(input) {
@@ -5506,18 +5787,31 @@ function renderEventWorkerVerifierWorkflow(input) {
5506
5787
  throw new Error("eventJson is required");
5507
5788
  if (!input.projectPath?.trim())
5508
5789
  throw new Error("projectPath is required");
5790
+ const seed = `${input.eventSource}:${input.eventType}:${input.eventId}`;
5791
+ const plan = worktreePlan(input, seed);
5509
5792
  const eventContext = {
5510
5793
  eventId: input.eventId,
5511
5794
  eventType: input.eventType,
5512
5795
  eventSource: input.eventSource,
5513
5796
  eventSubject: input.eventSubject,
5514
5797
  eventMessage: input.eventMessage,
5515
- projectPath: input.projectPath
5798
+ projectPath: input.projectPath,
5799
+ routeProjectPath: input.routeProjectPath,
5800
+ projectGroup: input.projectGroup,
5801
+ worktree: {
5802
+ mode: plan.mode,
5803
+ enabled: plan.enabled,
5804
+ cwd: plan.cwd,
5805
+ path: plan.path,
5806
+ branch: plan.branch,
5807
+ reason: plan.reason
5808
+ }
5516
5809
  };
5517
5810
  const workerPrompt = [
5518
5811
  `/goal Handle Hasna event ${input.eventSource}/${input.eventType} (${input.eventId}) in ${input.projectPath}.`,
5519
5812
  "",
5520
5813
  "You are the worker agent for an event-triggered OpenLoops workflow.",
5814
+ worktreePrompt(plan),
5521
5815
  "Investigate first before changing files. Read the full event envelope and decide the narrow action required by that event. Preserve unrelated user changes and update the relevant local CLI/task/knowledge system with evidence, changed files, commits, and blockers.",
5522
5816
  "If the event is informational or does not require action, record that finding and stop without making changes.",
5523
5817
  "",
@@ -5529,6 +5823,7 @@ function renderEventWorkerVerifierWorkflow(input) {
5529
5823
  `/goal Verify handling of Hasna event ${input.eventSource}/${input.eventType} (${input.eventId}).`,
5530
5824
  "",
5531
5825
  "You are the verifier agent for an event-triggered OpenLoops workflow.",
5826
+ worktreePrompt(plan),
5532
5827
  "Use fresh context. Inspect the event, repository/project state, worker evidence, tests, and any created tasks or notes. Act as an adversarial reviewer focused on correctness, regressions, security, missing evidence, and incomplete requirements.",
5533
5828
  "If the work is valid, record verification evidence in the relevant local system. If it is not valid, add precise follow-up tasks/comments and leave the event handling state open or blocked with clear evidence.",
5534
5829
  "",
@@ -5540,12 +5835,12 @@ function renderEventWorkerVerifierWorkflow(input) {
5540
5835
  name: `event-${input.eventSource}-${input.eventType}-${input.eventId.slice(0, 8)}-worker-verifier`.replace(/[^a-zA-Z0-9._:-]+/g, "-"),
5541
5836
  description: `Event-triggered worker/verifier workflow for ${input.eventSource}/${input.eventType}`,
5542
5837
  version: 1,
5543
- steps: [
5838
+ steps: workflowStepsWithWorktree(plan, [
5544
5839
  {
5545
5840
  id: "worker",
5546
5841
  name: "Worker",
5547
5842
  description: "Handle the Hasna event and record evidence.",
5548
- target: agentTarget(input, workerPrompt, "worker", `${input.eventSource}:${input.eventType}:${input.eventId}`),
5843
+ target: agentTarget(input, workerPrompt, "worker", seed, plan),
5549
5844
  timeoutMs: 45 * 60000
5550
5845
  },
5551
5846
  {
@@ -5553,10 +5848,10 @@ function renderEventWorkerVerifierWorkflow(input) {
5553
5848
  name: "Verifier",
5554
5849
  description: "Adversarially verify event handling.",
5555
5850
  dependsOn: ["worker"],
5556
- target: agentTarget(input, verifierPrompt, "verifier", `${input.eventSource}:${input.eventType}:${input.eventId}`),
5851
+ target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
5557
5852
  timeoutMs: 30 * 60000
5558
5853
  }
5559
- ]
5854
+ ])
5560
5855
  };
5561
5856
  }
5562
5857
  function renderBoundedAgentWorkerVerifierWorkflow(input) {
@@ -5565,11 +5860,13 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5565
5860
  if (!input.projectPath?.trim())
5566
5861
  throw new Error("projectPath is required");
5567
5862
  const seed = `${input.projectPath}:${input.objective}`;
5863
+ const plan = worktreePlan(input, seed);
5568
5864
  const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
5569
5865
  const workerPrompt = [
5570
5866
  `/goal ${input.objective}`,
5571
5867
  "",
5572
5868
  "You are the worker step for a bounded OpenLoops agent workflow.",
5869
+ worktreePrompt(plan),
5573
5870
  "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.",
5574
5871
  "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.",
5575
5872
  input.prompt ? "" : undefined,
@@ -5580,6 +5877,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5580
5877
  `/goal Adversarially verify: ${input.objective}`,
5581
5878
  "",
5582
5879
  "You are the verifier step for a bounded OpenLoops agent workflow.",
5880
+ worktreePrompt(plan),
5583
5881
  "Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
5584
5882
  "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."
5585
5883
  ].join(`
@@ -5588,12 +5886,12 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5588
5886
  name: input.name ?? `bounded-agent-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
5589
5887
  description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
5590
5888
  version: 1,
5591
- steps: [
5889
+ steps: workflowStepsWithWorktree(plan, [
5592
5890
  {
5593
5891
  id: "worker",
5594
5892
  name: "Worker",
5595
5893
  description: "Execute the bounded objective and record evidence.",
5596
- target: agentTarget(input, workerPrompt, "worker", seed),
5894
+ target: agentTarget(input, workerPrompt, "worker", seed, plan),
5597
5895
  timeoutMs
5598
5896
  },
5599
5897
  {
@@ -5601,10 +5899,10 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5601
5899
  name: "Verifier",
5602
5900
  description: "Adversarially verify the bounded objective result.",
5603
5901
  dependsOn: ["worker"],
5604
- target: agentTarget(input, verifierPrompt, "verifier", seed),
5902
+ target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
5605
5903
  timeoutMs: Math.min(timeoutMs, 30 * 60000)
5606
5904
  }
5607
- ]
5905
+ ])
5608
5906
  };
5609
5907
  }
5610
5908
  function renderLoopTemplate(id, values) {
@@ -5614,6 +5912,8 @@ function renderLoopTemplate(id, values) {
5614
5912
  taskTitle: values.taskTitle,
5615
5913
  taskDescription: values.taskDescription,
5616
5914
  projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
5915
+ routeProjectPath: values.routeProjectPath,
5916
+ projectGroup: values.projectGroup,
5617
5917
  provider: values.provider,
5618
5918
  authProfile: values.authProfile,
5619
5919
  authProfilePool: listVar(values.authProfilePool),
@@ -5626,6 +5926,9 @@ function renderLoopTemplate(id, values) {
5626
5926
  agent: values.agent,
5627
5927
  permissionMode: values.permissionMode,
5628
5928
  sandbox: values.sandbox,
5929
+ worktreeMode: values.worktreeMode,
5930
+ worktreeRoot: values.worktreeRoot,
5931
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
5629
5932
  eventId: values.eventId,
5630
5933
  eventType: values.eventType
5631
5934
  });
@@ -5639,6 +5942,8 @@ function renderLoopTemplate(id, values) {
5639
5942
  eventMessage: values.eventMessage,
5640
5943
  eventJson: values.eventJson ?? "",
5641
5944
  projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
5945
+ routeProjectPath: values.routeProjectPath,
5946
+ projectGroup: values.projectGroup,
5642
5947
  provider: values.provider,
5643
5948
  authProfile: values.authProfile,
5644
5949
  authProfilePool: listVar(values.authProfilePool),
@@ -5650,7 +5955,10 @@ function renderLoopTemplate(id, values) {
5650
5955
  variant: values.variant,
5651
5956
  agent: values.agent,
5652
5957
  permissionMode: values.permissionMode,
5653
- sandbox: values.sandbox
5958
+ sandbox: values.sandbox,
5959
+ worktreeMode: values.worktreeMode,
5960
+ worktreeRoot: values.worktreeRoot,
5961
+ worktreeBranchPrefix: values.worktreeBranchPrefix
5654
5962
  });
5655
5963
  }
5656
5964
  if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
@@ -5659,6 +5967,8 @@ function renderLoopTemplate(id, values) {
5659
5967
  objective: values.objective ?? "",
5660
5968
  prompt: values.prompt,
5661
5969
  projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
5970
+ routeProjectPath: values.routeProjectPath,
5971
+ projectGroup: values.projectGroup,
5662
5972
  provider: values.provider,
5663
5973
  authProfile: values.authProfile,
5664
5974
  authProfilePool: listVar(values.authProfilePool),
@@ -5671,6 +5981,9 @@ function renderLoopTemplate(id, values) {
5671
5981
  agent: values.agent,
5672
5982
  permissionMode: values.permissionMode,
5673
5983
  sandbox: values.sandbox,
5984
+ worktreeMode: values.worktreeMode,
5985
+ worktreeRoot: values.worktreeRoot,
5986
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
5674
5987
  timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
5675
5988
  });
5676
5989
  }
@@ -5922,9 +6235,9 @@ function ensureTodosTaskList(project, slug, name, description) {
5922
6235
  }
5923
6236
  function backupLoopsDatabase(reason) {
5924
6237
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
5925
- const backupDir = join3(dataDir(), "backups");
6238
+ const backupDir = join4(dataDir(), "backups");
5926
6239
  mkdirSync5(backupDir, { recursive: true, mode: 448 });
5927
- const backupPath = join3(backupDir, `loops.db.bak-${reason}-${stamp}`);
6240
+ const backupPath = join4(backupDir, `loops.db.bak-${reason}-${stamp}`);
5928
6241
  const db = new Database2(dbPath(), { readonly: true });
5929
6242
  try {
5930
6243
  writeFileSync3(backupPath, db.serialize(), { mode: 384 });
@@ -5938,11 +6251,11 @@ function stableHash(parts) {
5938
6251
  `)).digest("hex").slice(0, 16);
5939
6252
  }
5940
6253
  function routeCursorsPath() {
5941
- return join3(dataDir(), "route-cursors.json");
6254
+ return join4(dataDir(), "route-cursors.json");
5942
6255
  }
5943
6256
  function readRouteCursors() {
5944
6257
  const path = routeCursorsPath();
5945
- if (!existsSync3(path))
6258
+ if (!existsSync4(path))
5946
6259
  return {};
5947
6260
  try {
5948
6261
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
@@ -5963,7 +6276,7 @@ function writeRouteEvidence(kind, value, evidenceDir) {
5963
6276
  return;
5964
6277
  mkdirSync5(evidenceDir, { recursive: true, mode: 448 });
5965
6278
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
5966
- const evidencePath = join3(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
6279
+ const evidencePath = join4(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
5967
6280
  writeFileSync3(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
5968
6281
  return evidencePath;
5969
6282
  }
@@ -6079,7 +6392,7 @@ function eventMetadata(event) {
6079
6392
  function stringField(value) {
6080
6393
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
6081
6394
  }
6082
- function slugSegment(value, fallback = "event") {
6395
+ function slugSegment2(value, fallback = "event") {
6083
6396
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
6084
6397
  }
6085
6398
  function stableSuffix(value) {
@@ -6218,6 +6531,111 @@ function taskRouteEligibility(data, metadata) {
6218
6531
  }
6219
6532
  return { eligible: true, tags };
6220
6533
  }
6534
+ function routeThrottleLimitsFromOpts(opts) {
6535
+ return {
6536
+ maxActive: positiveInteger(opts.maxActive, "--max-active"),
6537
+ maxActivePerProject: positiveInteger(opts.maxActivePerProject, "--max-active-per-project"),
6538
+ maxActivePerProjectGroup: positiveInteger(opts.maxActivePerProjectGroup, "--max-active-per-project-group")
6539
+ };
6540
+ }
6541
+ function hasThrottleLimits(limits) {
6542
+ return limits.maxActive !== undefined || limits.maxActivePerProject !== undefined || limits.maxActivePerProjectGroup !== undefined;
6543
+ }
6544
+ function normalizeRoutePath(value) {
6545
+ if (!value?.trim())
6546
+ return;
6547
+ const resolved = resolve2(value.trim());
6548
+ let canonical = resolved;
6549
+ try {
6550
+ canonical = realpathSync(resolved);
6551
+ } catch {
6552
+ return canonical;
6553
+ }
6554
+ const gitRoot = spawnSync5("git", ["-C", canonical, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
6555
+ if (gitRoot.status === 0 && gitRoot.stdout.trim()) {
6556
+ try {
6557
+ return realpathSync(gitRoot.stdout.trim());
6558
+ } catch {
6559
+ return resolve2(gitRoot.stdout.trim());
6560
+ }
6561
+ }
6562
+ return canonical;
6563
+ }
6564
+ function routeProjectGroup(optsGroup, data, metadata) {
6565
+ return optsGroup?.trim() || taskEventField(data, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]) || taskEventField(metadata, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]);
6566
+ }
6567
+ function firstWorkflowRouting(workflow) {
6568
+ for (const step of workflow.steps) {
6569
+ if (step.target.type !== "agent")
6570
+ continue;
6571
+ const target = step.target;
6572
+ const projectPath = normalizeRoutePath(target.routing?.projectPath ?? target.worktree?.originalCwd ?? target.cwd);
6573
+ const projectGroup = target.routing?.projectGroup?.trim();
6574
+ if (projectPath || projectGroup) {
6575
+ return {
6576
+ ...projectPath ? { projectPath } : {},
6577
+ ...projectGroup ? { projectGroup } : {}
6578
+ };
6579
+ }
6580
+ }
6581
+ return;
6582
+ }
6583
+ function activeWorkflowLoopRoutes(store) {
6584
+ const values = [];
6585
+ for (const loop of store.listLoops({ status: "active", limit: 1e4 })) {
6586
+ if (loop.target.type !== "workflow")
6587
+ continue;
6588
+ const workflow = store.getWorkflow(loop.target.workflowId);
6589
+ if (!workflow || workflow.status !== "active")
6590
+ continue;
6591
+ const routing = firstWorkflowRouting(workflow);
6592
+ if (routing)
6593
+ values.push({ loop, routing });
6594
+ }
6595
+ return values;
6596
+ }
6597
+ function routeThrottleDecision(store, args) {
6598
+ const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
6599
+ const projectGroup = args.projectGroup?.trim() || undefined;
6600
+ const active = activeWorkflowLoopRoutes(store);
6601
+ const counts = {
6602
+ global: active.length,
6603
+ project: active.filter((entry) => normalizeRoutePath(entry.routing.projectPath) === projectPath).length
6604
+ };
6605
+ if (projectGroup)
6606
+ counts.projectGroup = active.filter((entry) => entry.routing.projectGroup?.trim() === projectGroup).length;
6607
+ const base = {
6608
+ projectPath,
6609
+ ...projectGroup ? { projectGroup } : {},
6610
+ limits: args.limits,
6611
+ counts
6612
+ };
6613
+ if (args.limits.maxActive !== undefined && counts.global >= args.limits.maxActive) {
6614
+ return { ...base, allowed: false, reason: `global active workflow limit reached (${counts.global}/${args.limits.maxActive})` };
6615
+ }
6616
+ if (args.limits.maxActivePerProject !== undefined && counts.project >= args.limits.maxActivePerProject) {
6617
+ return { ...base, allowed: false, reason: `project active workflow limit reached (${counts.project}/${args.limits.maxActivePerProject})` };
6618
+ }
6619
+ if (projectGroup && args.limits.maxActivePerProjectGroup !== undefined && counts.projectGroup !== undefined && counts.projectGroup >= args.limits.maxActivePerProjectGroup) {
6620
+ return {
6621
+ ...base,
6622
+ allowed: false,
6623
+ reason: `project-group active workflow limit reached (${counts.projectGroup}/${args.limits.maxActivePerProjectGroup})`
6624
+ };
6625
+ }
6626
+ return { ...base, allowed: true };
6627
+ }
6628
+ function routeThrottleDryRunPreview(args) {
6629
+ const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
6630
+ const projectGroup = args.projectGroup?.trim() || undefined;
6631
+ return {
6632
+ evaluated: false,
6633
+ reason: "not evaluated in dry-run because opening the live loop store may create or migrate the database",
6634
+ projectPath,
6635
+ ...projectGroup ? { projectGroup } : {},
6636
+ limits: args.limits
6637
+ };
6638
+ }
6221
6639
  async function readEventEnvelopeFromStdin() {
6222
6640
  const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
6223
6641
  const event = JSON.parse(raw);
@@ -6381,7 +6799,7 @@ templates.command("create-workflow <id>").description("render and store a templa
6381
6799
  }
6382
6800
  });
6383
6801
  var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
6384
- eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
6802
+ eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
6385
6803
  const event = await readEventEnvelopeFromStdin();
6386
6804
  const data = eventData(event);
6387
6805
  const metadata = eventMetadata(event);
@@ -6405,7 +6823,34 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
6405
6823
  "cwd"
6406
6824
  ]);
6407
6825
  const projectPath = opts.projectPath ?? dataProjectPath ?? metadataProjectPath ?? process.cwd();
6826
+ const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
6827
+ const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
6828
+ const throttleLimits = routeThrottleLimitsFromOpts(opts);
6408
6829
  const idempotencyKey = `todos-task:${taskId}:${event.type}`;
6830
+ const idempotencySuffix = stableSuffix(idempotencyKey);
6831
+ const workflowName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
6832
+ const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
6833
+ const legacyLoopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
6834
+ if (!opts.dryRun) {
6835
+ const store2 = new Store;
6836
+ try {
6837
+ const existingLoop = store2.findLoopByName(loopName) ?? store2.findLoopByName(legacyLoopName);
6838
+ if (existingLoop) {
6839
+ const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
6840
+ print({
6841
+ deduped: true,
6842
+ idempotencyKey,
6843
+ dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
6844
+ event,
6845
+ workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
6846
+ loop: publicLoop(existingLoop)
6847
+ }, `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
6848
+ return;
6849
+ }
6850
+ } finally {
6851
+ store2.close();
6852
+ }
6853
+ }
6409
6854
  const provider = opts.provider;
6410
6855
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
6411
6856
  throw new Error("unsupported provider");
@@ -6417,6 +6862,8 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
6417
6862
  taskTitle,
6418
6863
  taskDescription,
6419
6864
  projectPath,
6865
+ routeProjectPath,
6866
+ projectGroup,
6420
6867
  provider,
6421
6868
  authProfile,
6422
6869
  authProfilePool: splitList(opts.authProfilePool),
@@ -6431,14 +6878,14 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
6431
6878
  agent: opts.agent,
6432
6879
  permissionMode,
6433
6880
  sandbox,
6881
+ worktreeMode: opts.worktreeMode,
6882
+ worktreeRoot: opts.worktreeRoot,
6883
+ worktreeBranchPrefix: opts.worktreeBranchPrefix,
6434
6884
  eventId: event.id,
6435
6885
  eventType: event.type
6436
6886
  });
6437
- const idempotencySuffix = stableSuffix(idempotencyKey);
6438
- workflowBody.name = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
6439
- workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}`;
6440
- const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
6441
- const legacyLoopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
6887
+ workflowBody.name = workflowName;
6888
+ workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
6442
6889
  const loopInput = {
6443
6890
  name: loopName,
6444
6891
  description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
@@ -6450,50 +6897,96 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
6450
6897
  leaseMs: 90 * 60000
6451
6898
  };
6452
6899
  if (opts.dryRun) {
6900
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
6453
6901
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
6454
6902
  name: workflowBody.name,
6455
6903
  type: "todos-task-event-workflow",
6456
6904
  event: event.id
6457
6905
  }, {}) : undefined;
6458
- print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, preflight }, `dry-run ${loopName}`);
6906
+ print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
6459
6907
  return;
6460
6908
  }
6461
6909
  const store = new Store;
6462
6910
  try {
6463
- const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
6464
- if (existingLoop) {
6465
- const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
6466
- print({
6467
- deduped: true,
6468
- idempotencyKey,
6469
- dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
6470
- event,
6471
- workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined,
6472
- loop: publicLoop(existingLoop)
6473
- }, `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
6474
- return;
6475
- }
6476
- const existingWorkflow = store.findWorkflowByName(workflowBody.name);
6477
- const workflowPreflightSpec = existingWorkflow ?? workflowSpecForPreflight(workflowBody, "event-preflight");
6911
+ const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
6912
+ const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
6478
6913
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
6479
6914
  name: workflowBody.name,
6480
6915
  type: "todos-task-event-workflow",
6481
6916
  event: event.id
6482
6917
  }, {}) : undefined;
6483
- const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
6484
- const loop = store.createLoop({
6485
- ...loopInput,
6486
- target: { type: "workflow", workflowId: workflow.id }
6918
+ const outcome = store.writeTransaction(() => {
6919
+ const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
6920
+ if (existingLoop) {
6921
+ const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
6922
+ return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
6923
+ }
6924
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
6925
+ if (throttle && !throttle.allowed)
6926
+ return { kind: "throttled", throttle };
6927
+ const existingWorkflow = store.findWorkflowByName(workflowBody.name);
6928
+ const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
6929
+ const loop = store.createLoop({
6930
+ ...loopInput,
6931
+ target: { type: "workflow", workflowId: workflow.id }
6932
+ });
6933
+ return { kind: "created", workflow, loop, throttle };
6487
6934
  });
6488
- print({ deduped: false, idempotencyKey, event, workflow: publicWorkflow(workflow), loop: publicLoop(loop), preflight }, `created ${loop.id} (${loop.name}) workflow=${workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
6935
+ if (outcome.kind === "deduped") {
6936
+ print({
6937
+ deduped: true,
6938
+ idempotencyKey,
6939
+ dedupedBy: outcome.existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
6940
+ event,
6941
+ workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
6942
+ loop: publicLoop(outcome.existingLoop)
6943
+ }, `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
6944
+ return;
6945
+ }
6946
+ if (outcome.kind === "throttled") {
6947
+ print({
6948
+ skipped: true,
6949
+ queuedAtSource: true,
6950
+ reason: outcome.throttle.reason,
6951
+ idempotencyKey,
6952
+ event,
6953
+ throttle: outcome.throttle,
6954
+ workflow: workflowBody,
6955
+ loop: loopInput
6956
+ }, `skipped task ${taskId}: ${outcome.throttle.reason}`);
6957
+ return;
6958
+ }
6959
+ print({ deduped: false, idempotencyKey, event, workflow: publicWorkflow(outcome.workflow), loop: publicLoop(outcome.loop), throttle: outcome.throttle, preflight }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
6489
6960
  } finally {
6490
6961
  store.close();
6491
6962
  }
6492
6963
  });
6493
- eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
6964
+ eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
6494
6965
  const event = await readEventEnvelopeFromStdin();
6495
6966
  const data = eventData(event);
6496
- const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
6967
+ const metadata = eventMetadata(event);
6968
+ const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "project_path", "projectPath", "project_canonical_path", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
6969
+ const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
6970
+ const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
6971
+ const throttleLimits = routeThrottleLimitsFromOpts(opts);
6972
+ const eventSuffix = event.id.slice(0, 8);
6973
+ const source = slugSegment2(event.source, "source");
6974
+ const type = slugSegment2(event.type, "type");
6975
+ const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
6976
+ const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
6977
+ if (!opts.dryRun) {
6978
+ const store2 = new Store;
6979
+ try {
6980
+ const existingLoop = store2.findLoopByName(loopName);
6981
+ if (existingLoop) {
6982
+ const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
6983
+ print({ deduped: true, event, workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
6984
+ return;
6985
+ }
6986
+ } finally {
6987
+ store2.close();
6988
+ }
6989
+ }
6497
6990
  const provider = opts.provider;
6498
6991
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
6499
6992
  throw new Error("unsupported provider");
@@ -6508,6 +7001,8 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
6508
7001
  eventMessage: stringField(event.message),
6509
7002
  eventJson: JSON.stringify(event),
6510
7003
  projectPath,
7004
+ routeProjectPath,
7005
+ projectGroup,
6511
7006
  provider,
6512
7007
  authProfile,
6513
7008
  authProfilePool: splitList(opts.authProfilePool),
@@ -6521,14 +7016,13 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
6521
7016
  variant: opts.variant,
6522
7017
  agent: opts.agent,
6523
7018
  permissionMode,
6524
- sandbox
7019
+ sandbox,
7020
+ worktreeMode: opts.worktreeMode,
7021
+ worktreeRoot: opts.worktreeRoot,
7022
+ worktreeBranchPrefix: opts.worktreeBranchPrefix
6525
7023
  });
6526
- const eventSuffix = event.id.slice(0, 8);
6527
- const source = slugSegment(event.source, "source");
6528
- const type = slugSegment(event.type, "type");
6529
- workflowBody.name = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
6530
- workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}`;
6531
- const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
7024
+ workflowBody.name = workflowName;
7025
+ workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
6532
7026
  const loopInput = {
6533
7027
  name: loopName,
6534
7028
  description: `Run ${workflowBody.name} once for event ${event.id}`,
@@ -6540,35 +7034,50 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
6540
7034
  leaseMs: 90 * 60000
6541
7035
  };
6542
7036
  if (opts.dryRun) {
7037
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
6543
7038
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
6544
7039
  name: workflowBody.name,
6545
7040
  type: "generic-event-workflow",
6546
7041
  event: event.id
6547
7042
  }, {}) : undefined;
6548
- print({ event, workflow: workflowBody, loop: loopInput, preflight }, `dry-run ${loopName}`);
7043
+ print({ event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
6549
7044
  return;
6550
7045
  }
6551
7046
  const store = new Store;
6552
7047
  try {
6553
- const existingLoop = store.findLoopByName(loopName);
6554
- if (existingLoop) {
6555
- const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
6556
- print({ deduped: true, event, workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
6557
- return;
6558
- }
6559
- const existingWorkflow = store.findWorkflowByName(workflowBody.name);
6560
- const workflowPreflightSpec = existingWorkflow ?? workflowSpecForPreflight(workflowBody, "event-preflight");
7048
+ const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
7049
+ const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
6561
7050
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
6562
7051
  name: workflowBody.name,
6563
7052
  type: "generic-event-workflow",
6564
7053
  event: event.id
6565
7054
  }, {}) : undefined;
6566
- const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
6567
- const loop = store.createLoop({
6568
- ...loopInput,
6569
- target: { type: "workflow", workflowId: workflow.id }
7055
+ const outcome = store.writeTransaction(() => {
7056
+ const existingLoop = store.findLoopByName(loopName);
7057
+ if (existingLoop) {
7058
+ const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
7059
+ return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
7060
+ }
7061
+ const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
7062
+ if (throttle && !throttle.allowed)
7063
+ return { kind: "throttled", throttle };
7064
+ const existingWorkflow = store.findWorkflowByName(workflowBody.name);
7065
+ const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
7066
+ const loop = store.createLoop({
7067
+ ...loopInput,
7068
+ target: { type: "workflow", workflowId: workflow.id }
7069
+ });
7070
+ return { kind: "created", workflow, loop, throttle };
6570
7071
  });
6571
- print({ deduped: false, event, workflow: publicWorkflow(workflow), loop: publicLoop(loop), preflight }, `created ${loop.id} (${loop.name}) workflow=${workflow.name}`);
7072
+ if (outcome.kind === "deduped") {
7073
+ print({ deduped: true, event, workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined, loop: publicLoop(outcome.existingLoop) }, `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name})`);
7074
+ return;
7075
+ }
7076
+ if (outcome.kind === "throttled") {
7077
+ print({ skipped: true, queuedAtSource: true, reason: outcome.throttle.reason, event, throttle: outcome.throttle, workflow: workflowBody, loop: loopInput }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
7078
+ return;
7079
+ }
7080
+ print({ deduped: false, event, workflow: publicWorkflow(outcome.workflow), loop: publicLoop(outcome.loop), throttle: outcome.throttle, preflight }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name}`);
6572
7081
  } finally {
6573
7082
  store.close();
6574
7083
  }
@@ -7402,7 +7911,7 @@ ${result.instructions.join(`
7402
7911
  });
7403
7912
  daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
7404
7913
  const path = daemonLogPath();
7405
- if (!existsSync3(path)) {
7914
+ if (!existsSync4(path)) {
7406
7915
  console.log("");
7407
7916
  return;
7408
7917
  }