@hasna/loops 0.4.1 → 0.4.2

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
@@ -1205,7 +1205,7 @@ function discardWorkflowRunManifest(staged) {
1205
1205
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1206
1206
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1207
1207
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1208
- var SCHEMA_USER_VERSION = 6;
1208
+ var SCHEMA_USER_VERSION = 7;
1209
1209
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1210
1210
  var PRUNE_BATCH_SIZE = 400;
1211
1211
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
@@ -1575,6 +1575,13 @@ class Store {
1575
1575
  this.addColumnIfMissing("loop_runs", "pgid", "INTEGER");
1576
1576
  this.addColumnIfMissing("loop_runs", "process_started_at", "TEXT");
1577
1577
  }
1578
+ },
1579
+ {
1580
+ id: "0007_run_claim_tokens",
1581
+ apply: () => {
1582
+ this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1583
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
1584
+ }
1578
1585
  }
1579
1586
  ];
1580
1587
  }
@@ -1616,6 +1623,7 @@ class Store {
1616
1623
  started_at TEXT,
1617
1624
  finished_at TEXT,
1618
1625
  claimed_by TEXT,
1626
+ claim_token TEXT,
1619
1627
  lease_expires_at TEXT,
1620
1628
  pid INTEGER,
1621
1629
  pgid INTEGER,
@@ -1634,6 +1642,7 @@ class Store {
1634
1642
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
1635
1643
  CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
1636
1644
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
1645
+ CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
1637
1646
 
1638
1647
  CREATE TABLE IF NOT EXISTS daemon_lease (
1639
1648
  id TEXT PRIMARY KEY,
@@ -3505,6 +3514,7 @@ class Store {
3505
3514
  }
3506
3515
  claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
3507
3516
  const startedAt = now.toISOString();
3517
+ const claimToken = opts.claimToken ?? genId();
3508
3518
  this.db.exec("BEGIN IMMEDIATE");
3509
3519
  try {
3510
3520
  this.assertDaemonLeaseFence(opts, startedAt);
@@ -3527,12 +3537,13 @@ class Store {
3527
3537
  return;
3528
3538
  }
3529
3539
  const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
3530
- claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3540
+ claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3531
3541
  duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
3532
3542
  WHERE id=$id AND status='running' AND lease_expires_at <= $now`).run({
3533
3543
  $id: existing.id,
3534
3544
  $started: startedAt,
3535
3545
  $claimedBy: runnerId,
3546
+ $claimToken: claimToken,
3536
3547
  $lease: leaseExpiresAt,
3537
3548
  $updated: startedAt,
3538
3549
  $now: startedAt
@@ -3541,7 +3552,7 @@ class Store {
3541
3552
  if (res3.changes !== 1)
3542
3553
  return;
3543
3554
  const run3 = this.getRun(existing.id);
3544
- return run3 ? { run: run3, loop } : undefined;
3555
+ return run3 ? { run: run3, loop, claimToken } : undefined;
3545
3556
  }
3546
3557
  if (existing.status === "succeeded" || existing.status === "skipped") {
3547
3558
  this.db.exec("COMMIT");
@@ -3549,7 +3560,7 @@ class Store {
3549
3560
  }
3550
3561
  const attempt = existing.attempt + 1;
3551
3562
  const res2 = this.db.query(`UPDATE loop_runs SET attempt=$attempt, status='running', started_at=$started, finished_at=NULL,
3552
- claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3563
+ claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3553
3564
  duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
3554
3565
  WHERE id=$id
3555
3566
  AND status IN ('failed', 'timed_out', 'abandoned')
@@ -3558,6 +3569,7 @@ class Store {
3558
3569
  $attempt: attempt,
3559
3570
  $started: startedAt,
3560
3571
  $claimedBy: runnerId,
3572
+ $claimToken: claimToken,
3561
3573
  $lease: leaseExpiresAt,
3562
3574
  $updated: startedAt,
3563
3575
  $maxAttempts: loop.maxAttempts
@@ -3566,12 +3578,12 @@ class Store {
3566
3578
  if (res2.changes !== 1)
3567
3579
  return;
3568
3580
  const run2 = this.getRun(existing.id);
3569
- return run2 ? { run: run2, loop } : undefined;
3581
+ return run2 ? { run: run2, loop, claimToken } : undefined;
3570
3582
  }
3571
3583
  const id = genId();
3572
3584
  const res = this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
3573
- claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
3574
- VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $lease,
3585
+ claimed_by, claim_token, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
3586
+ VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $claimToken, $lease,
3575
3587
  NULL, NULL, NULL, NULL, NULL, NULL, $created, $updated)`).run({
3576
3588
  $id: id,
3577
3589
  $loopId: loop.id,
@@ -3579,6 +3591,7 @@ class Store {
3579
3591
  $scheduledFor: scheduledFor,
3580
3592
  $started: startedAt,
3581
3593
  $claimedBy: runnerId,
3594
+ $claimToken: claimToken,
3582
3595
  $lease: leaseExpiresAt,
3583
3596
  $created: startedAt,
3584
3597
  $updated: startedAt
@@ -3587,7 +3600,7 @@ class Store {
3587
3600
  if (res.changes !== 1)
3588
3601
  return;
3589
3602
  const run = this.getRun(id);
3590
- return run ? { run, loop } : undefined;
3603
+ return run ? { run, loop, claimToken } : undefined;
3591
3604
  } catch (error) {
3592
3605
  try {
3593
3606
  this.db.exec("ROLLBACK");
@@ -3610,16 +3623,18 @@ class Store {
3610
3623
  $error: error ?? null,
3611
3624
  $updated: finishedAt,
3612
3625
  $claimedBy: opts.claimedBy ?? null,
3626
+ $claimToken: opts.claimToken ?? null,
3613
3627
  $now: (opts.now ?? new Date).toISOString(),
3614
3628
  $daemonLeaseId: opts.daemonLeaseId ?? null
3615
3629
  };
3616
3630
  return this.transact(() => {
3617
- const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3631
+ const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3618
3632
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3619
3633
  WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
3634
+ AND ($claimToken IS NULL OR claim_token=$claimToken)
3620
3635
  AND ($daemonLeaseId IS NULL OR EXISTS (
3621
3636
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3622
- ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3637
+ ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3623
3638
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
3624
3639
  const run = this.getRun(id);
3625
3640
  if (!run)
@@ -3647,11 +3662,13 @@ class Store {
3647
3662
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
3648
3663
  const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
3649
3664
  WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
3665
+ AND ($claimToken IS NULL OR claim_token=$claimToken)
3650
3666
  AND ($daemonLeaseId IS NULL OR EXISTS (
3651
3667
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3652
3668
  ))`).run({
3653
3669
  $id: id,
3654
3670
  $claimedBy: claimedBy,
3671
+ $claimToken: opts.claimToken ?? null,
3655
3672
  $expires: expiresAt,
3656
3673
  $updated: now.toISOString(),
3657
3674
  $now: now.toISOString(),
@@ -3967,7 +3984,7 @@ class Store {
3967
3984
  // package.json
3968
3985
  var package_default = {
3969
3986
  name: "@hasna/loops",
3970
- version: "0.4.1",
3987
+ version: "0.4.2",
3971
3988
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
3972
3989
  type: "module",
3973
3990
  main: "dist/index.js",
@@ -4007,6 +4024,22 @@ var package_default = {
4007
4024
  "./storage": {
4008
4025
  types: "./dist/lib/store.d.ts",
4009
4026
  import: "./dist/lib/store.js"
4027
+ },
4028
+ "./storage/contract": {
4029
+ types: "./dist/lib/storage/contract.d.ts",
4030
+ import: "./dist/lib/storage/contract.js"
4031
+ },
4032
+ "./storage/sqlite": {
4033
+ types: "./dist/lib/storage/sqlite.d.ts",
4034
+ import: "./dist/lib/storage/sqlite.js"
4035
+ },
4036
+ "./storage/postgres": {
4037
+ types: "./dist/lib/storage/postgres.d.ts",
4038
+ import: "./dist/lib/storage/postgres.js"
4039
+ },
4040
+ "./storage/postgres-schema": {
4041
+ types: "./dist/lib/storage/postgres-schema.d.ts",
4042
+ import: "./dist/lib/storage/postgres-schema.js"
4010
4043
  }
4011
4044
  },
4012
4045
  files: [
@@ -4017,7 +4050,7 @@ var package_default = {
4017
4050
  "LICENSE"
4018
4051
  ],
4019
4052
  scripts: {
4020
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
4053
+ build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
4021
4054
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
4022
4055
  typecheck: "tsc --noEmit",
4023
4056
  test: "bun test",
@@ -4280,11 +4313,12 @@ function publicLoop(loop) {
4280
4313
  target
4281
4314
  };
4282
4315
  }
4283
- function publicRun(run, showOutput = false) {
4316
+ function publicRun(run, showOutput = false, opts = {}) {
4284
4317
  return {
4285
4318
  ...run,
4286
4319
  stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
4287
- stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
4320
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
4321
+ error: opts.redactError ? redact(run.error) : run.error
4288
4322
  };
4289
4323
  }
4290
4324
  function publicExecutorResult(result, showOutput = false) {
@@ -8287,6 +8321,7 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8287
8321
  { name: "taskTitle", description: "Human-readable task title." },
8288
8322
  projectPathVariable(),
8289
8323
  { name: "todosProjectPath", description: "Todos storage project path used in worker/verifier commands." },
8324
+ { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
8290
8325
  ...routeScopeVariables(),
8291
8326
  ...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
8292
8327
  ]
@@ -8327,6 +8362,8 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8327
8362
  variables: [
8328
8363
  { name: "taskId", required: true, description: "Todos task id." },
8329
8364
  projectPathVariable(),
8365
+ { name: "todosProjectPath", description: "Todos storage project path used in lifecycle commands." },
8366
+ { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
8330
8367
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
8331
8368
  roleAuthProfileVariable("triage"),
8332
8369
  roleAuthProfileVariable("planner"),
@@ -8457,28 +8494,29 @@ function verifierRuntimeGuidance(input) {
8457
8494
  ].join(`
8458
8495
  `);
8459
8496
  }
8460
- function todosInspectLine(todosProjectPath, taskId) {
8461
- return `- Inspect first: todos --project ${todosProjectPath} inspect ${taskId}`;
8497
+ function todosInspectLine(todosProjectPath, taskId, todosDbPath) {
8498
+ return `- Inspect first: ${todosCommand(todosProjectPath, todosDbPath)} inspect ${taskId}`;
8462
8499
  }
8463
- function todosStartLine(todosProjectPath, taskId) {
8464
- return `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${taskId}`;
8500
+ function todosStartLine(todosProjectPath, taskId, todosDbPath) {
8501
+ return `- Claim/start if appropriate: ${todosCommand(todosProjectPath, todosDbPath)} start ${taskId}`;
8465
8502
  }
8466
- function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
8467
- return `- Record evidence: todos --project ${todosProjectPath} comment ${taskId} "<${placeholder}>"`;
8503
+ function todosEvidenceLine(todosProjectPath, taskId, placeholder, todosDbPath) {
8504
+ return `- Record evidence: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<${placeholder}>"`;
8468
8505
  }
8469
- function todosVerificationLine(todosProjectPath, taskId) {
8470
- return `- Record verification: todos --project ${todosProjectPath} comment ${taskId} "<verification evidence or blocker>"`;
8506
+ function todosVerificationLine(todosProjectPath, taskId, todosDbPath) {
8507
+ return `- Record verification: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<verification evidence or blocker>"`;
8471
8508
  }
8472
- function todosDoneLine(todosProjectPath, taskId) {
8473
- return `- If valid and complete: todos --project ${todosProjectPath} done ${taskId}`;
8509
+ function todosDoneLine(todosProjectPath, taskId, todosDbPath) {
8510
+ return `- If valid and complete: ${todosCommand(todosProjectPath, todosDbPath)} done ${taskId}`;
8474
8511
  }
8475
- function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
8512
+ function todosExactCommandsFragment(todosProjectPath, taskId, commandLines, todosDbPath) {
8476
8513
  return [
8477
8514
  `Todos project path: ${todosProjectPath}`,
8515
+ todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
8478
8516
  "Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
8479
- todosInspectLine(todosProjectPath, taskId),
8517
+ todosInspectLine(todosProjectPath, taskId, todosDbPath),
8480
8518
  ...commandLines
8481
- ];
8519
+ ].filter((line) => Boolean(line));
8482
8520
  }
8483
8521
  function worktreePrompt(plan) {
8484
8522
  if (plan.enabled) {
@@ -8515,10 +8553,19 @@ function worktreeContextFragment(plan) {
8515
8553
  function shellQuote3(value) {
8516
8554
  return `'${value.replace(/'/g, `'\\''`)}'`;
8517
8555
  }
8518
- function sourceTaskGateCommand(todosProjectPath, taskId) {
8556
+ function todosEnvPrefix(todosDbPath) {
8557
+ return todosDbPath ? `TODOS_DB_PATH=${shellQuote3(todosDbPath)} HASNA_TODOS_DB_PATH=${shellQuote3(todosDbPath)} ` : "";
8558
+ }
8559
+ function todosCommand(todosProjectPath, todosDbPath) {
8560
+ return `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath}`;
8561
+ }
8562
+ function shellTodosCommand(todosProjectPath, todosDbPath) {
8563
+ return `${todosEnvPrefix(todosDbPath)}todos --project ${shellQuote3(todosProjectPath)}`;
8564
+ }
8565
+ function sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath) {
8519
8566
  return [
8520
8567
  "set -euo pipefail",
8521
- `todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
8568
+ `${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
8522
8569
  `printf "source task %s resolved in todos project %s\\n" ${shellQuote3(taskId)} ${shellQuote3(todosProjectPath)}`
8523
8570
  ].join(`
8524
8571
  `);
@@ -8578,10 +8625,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
8578
8625
  "console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
8579
8626
  ].join(`
8580
8627
  `);
8581
- function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
8628
+ function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker, todosDbPath) {
8582
8629
  return [
8583
8630
  "set -euo pipefail",
8584
- `task_json="$(todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)})"`,
8631
+ `task_json="$(${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)})"`,
8585
8632
  `TASK_JSON="$task_json" STAGE=${shellQuote3(stage)} bun - <<'BUN'`,
8586
8633
  LIFECYCLE_GATE_SCRIPT_HEAD,
8587
8634
  `const goMarker = ${JSON.stringify(goMarker)};`,
@@ -8597,6 +8644,7 @@ var PR_HANDOFF_SCRIPT = [
8597
8644
  "const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
8598
8645
  "const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
8599
8646
  "const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
8647
+ "const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
8600
8648
  "const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
8601
8649
  "const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
8602
8650
  "const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
@@ -8614,7 +8662,8 @@ var PR_HANDOFF_SCRIPT = [
8614
8662
  "};",
8615
8663
  "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
8616
8664
  "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
8617
- "const todos = (...args) => run(todosBin, todosArgs(...args));",
8665
+ "const todosEnv = todosDbPath ? { ...process.env, TODOS_DB_PATH: todosDbPath, HASNA_TODOS_DB_PATH: todosDbPath } : process.env;",
8666
+ "const todos = (...args) => run(todosBin, todosArgs(...args), { env: todosEnv });",
8618
8667
  "const comment = (text) => {",
8619
8668
  " const result = todos('comment', taskId, text);",
8620
8669
  " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
@@ -8743,6 +8792,7 @@ function prHandoffCommand(opts) {
8743
8792
  `export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote3(opts.artifactPath)}`,
8744
8793
  `export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote3(opts.taskId)}`,
8745
8794
  `export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote3(opts.todosProjectPath)}`,
8795
+ opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote3(opts.todosDbPath)}` : undefined,
8746
8796
  `export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote3(opts.worktreeCwd)}`,
8747
8797
  `export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
8748
8798
  `export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
@@ -8753,7 +8803,7 @@ function prHandoffCommand(opts) {
8753
8803
  "bun - <<'BUN'",
8754
8804
  PR_HANDOFF_SCRIPT,
8755
8805
  "BUN"
8756
- ].join(`
8806
+ ].filter((line) => Boolean(line)).join(`
8757
8807
  `);
8758
8808
  }
8759
8809
  // src/lib/templates-custom.ts
@@ -9343,12 +9393,12 @@ function commandStep(opts) {
9343
9393
  ...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
9344
9394
  };
9345
9395
  }
9346
- function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
9396
+ function sourceTaskGateStep(todosProjectPath, taskId, plan, description, todosDbPath) {
9347
9397
  return commandStep({
9348
9398
  id: "source-task-gate",
9349
9399
  name: "Source Task Gate",
9350
9400
  description,
9351
- command: sourceTaskGateCommand(todosProjectPath, taskId),
9401
+ command: sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath),
9352
9402
  cwd: plan.originalCwd,
9353
9403
  timeoutMs: 60000,
9354
9404
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9360,7 +9410,7 @@ function lifecycleGateStep(opts) {
9360
9410
  name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
9361
9411
  description: opts.description,
9362
9412
  dependsOn: opts.dependsOn,
9363
- command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker),
9413
+ command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker, opts.todosDbPath),
9364
9414
  cwd: opts.plan.originalCwd,
9365
9415
  timeoutMs: 2 * 60000,
9366
9416
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9369,7 +9419,7 @@ function lifecycleGateStep(opts) {
9369
9419
  function prHandoffArtifactPath(plan, taskId) {
9370
9420
  return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
9371
9421
  }
9372
- function prHandoffStep(input, plan, todosProjectPath) {
9422
+ function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
9373
9423
  return commandStep({
9374
9424
  id: "pr-handoff",
9375
9425
  name: "PR Handoff",
@@ -9379,6 +9429,7 @@ function prHandoffStep(input, plan, todosProjectPath) {
9379
9429
  artifactPath: prHandoffArtifactPath(plan, input.taskId),
9380
9430
  taskId: input.taskId,
9381
9431
  todosProjectPath,
9432
+ todosDbPath,
9382
9433
  worktreeCwd: plan.cwd,
9383
9434
  worktreeRoot: plan.path ?? plan.cwd,
9384
9435
  expectedBranch: plan.branch ?? ""
@@ -9465,6 +9516,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9465
9516
  if (!input.projectPath?.trim())
9466
9517
  throw new Error("projectPath is required");
9467
9518
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
9519
+ const todosDbPath = input.todosDbPath;
9468
9520
  const plan = worktreePlan(input, input.taskId);
9469
9521
  const taskContext = {
9470
9522
  taskId: input.taskId,
@@ -9475,15 +9527,17 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9475
9527
  projectPath: input.projectPath,
9476
9528
  routeProjectPath: input.routeProjectPath,
9477
9529
  projectGroup: input.projectGroup,
9530
+ todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
9531
+ todosDbPath,
9478
9532
  worktree: worktreeContextFragment(plan)
9479
9533
  };
9480
9534
  const workerPrompt = [
9481
9535
  ...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
9482
9536
  worktreePrompt(plan),
9483
9537
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9484
- todosStartLine(todosProjectPath, input.taskId),
9485
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
9486
- ]),
9538
+ todosStartLine(todosProjectPath, input.taskId, todosDbPath),
9539
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers", todosDbPath)
9540
+ ], todosDbPath),
9487
9541
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
9488
9542
  "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.",
9489
9543
  NO_TMUX_DISPATCH_FRAGMENT,
@@ -9496,9 +9550,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9496
9550
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
9497
9551
  worktreePrompt(plan),
9498
9552
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9499
- todosVerificationLine(todosProjectPath, input.taskId),
9500
- todosDoneLine(todosProjectPath, input.taskId)
9501
- ]),
9553
+ todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
9554
+ todosDoneLine(todosProjectPath, input.taskId, todosDbPath)
9555
+ ], todosDbPath),
9502
9556
  adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
9503
9557
  verifierRuntimeGuidance(input),
9504
9558
  TASK_VERIFIER_DECISION_FRAGMENT,
@@ -9513,7 +9567,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9513
9567
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
9514
9568
  version: 1,
9515
9569
  steps: [
9516
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."),
9570
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable.", todosDbPath),
9517
9571
  ...workerVerifierSteps({
9518
9572
  input,
9519
9573
  seed: input.taskId,
@@ -9533,6 +9587,7 @@ function renderTaskLifecycleWorkflow(input) {
9533
9587
  if (!input.projectPath?.trim())
9534
9588
  throw new Error("projectPath is required");
9535
9589
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
9590
+ const todosDbPath = input.todosDbPath;
9536
9591
  const plan = worktreePlan(input, input.taskId);
9537
9592
  const taskContext = {
9538
9593
  taskId: input.taskId,
@@ -9544,6 +9599,7 @@ function renderTaskLifecycleWorkflow(input) {
9544
9599
  routeProjectPath: input.routeProjectPath,
9545
9600
  projectGroup: input.projectGroup,
9546
9601
  todosProjectPath,
9602
+ todosDbPath,
9547
9603
  worktree: worktreeContextFragment(plan)
9548
9604
  };
9549
9605
  const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
@@ -9557,8 +9613,8 @@ function renderTaskLifecycleWorkflow(input) {
9557
9613
  const shared = [
9558
9614
  worktreePrompt(plan),
9559
9615
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9560
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
9561
- ]),
9616
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker", todosDbPath)
9617
+ ], todosDbPath),
9562
9618
  NO_TMUX_DISPATCH_FRAGMENT,
9563
9619
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
9564
9620
  "",
@@ -9567,7 +9623,7 @@ function renderTaskLifecycleWorkflow(input) {
9567
9623
  ].join(`
9568
9624
  `);
9569
9625
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
9570
- const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
9626
+ const blockTaskCommand = `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
9571
9627
  const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
9572
9628
  const triagePrompt = [
9573
9629
  ...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
@@ -9595,7 +9651,7 @@ function renderTaskLifecycleWorkflow(input) {
9595
9651
  const workerPrompt = [
9596
9652
  ...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
9597
9653
  shared,
9598
- todosStartLine(todosProjectPath, input.taskId),
9654
+ todosStartLine(todosProjectPath, input.taskId, todosDbPath),
9599
9655
  "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
9600
9656
  input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
9601
9657
  WORKER_LEAVES_COMPLETION_FRAGMENT
@@ -9604,8 +9660,8 @@ function renderTaskLifecycleWorkflow(input) {
9604
9660
  const verifierPrompt = [
9605
9661
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
9606
9662
  shared,
9607
- todosVerificationLine(todosProjectPath, input.taskId),
9608
- todosDoneLine(todosProjectPath, input.taskId),
9663
+ todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
9664
+ todosDoneLine(todosProjectPath, input.taskId, todosDbPath),
9609
9665
  adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
9610
9666
  verifierRuntimeGuidance(input),
9611
9667
  input.prHandoff ? `If ${handoffArtifactPath} exists and there is no PR URL evidence, verify that the PR handoff step queued or completed a bounded handoff; leave the original task open or blocked until PR evidence is recorded.` : undefined,
@@ -9614,7 +9670,7 @@ function renderTaskLifecycleWorkflow(input) {
9614
9670
  ].filter(Boolean).join(`
9615
9671
  `);
9616
9672
  const steps = [
9617
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."),
9673
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable.", todosDbPath),
9618
9674
  {
9619
9675
  id: "triage",
9620
9676
  name: "Triage",
@@ -9628,6 +9684,7 @@ function renderTaskLifecycleWorkflow(input) {
9628
9684
  description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
9629
9685
  dependsOn: ["triage"],
9630
9686
  todosProjectPath,
9687
+ todosDbPath,
9631
9688
  taskId: input.taskId,
9632
9689
  goMarker: gateMarker("triage", "go"),
9633
9690
  blockedMarker: gateMarker("triage", "blocked"),
@@ -9646,6 +9703,7 @@ function renderTaskLifecycleWorkflow(input) {
9646
9703
  description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
9647
9704
  dependsOn: ["planner"],
9648
9705
  todosProjectPath,
9706
+ todosDbPath,
9649
9707
  taskId: input.taskId,
9650
9708
  goMarker: gateMarker("planner", "go"),
9651
9709
  blockedMarker: gateMarker("planner", "blocked"),
@@ -9661,7 +9719,7 @@ function renderTaskLifecycleWorkflow(input) {
9661
9719
  }
9662
9720
  ];
9663
9721
  if (input.prHandoff) {
9664
- steps.push(prHandoffStep(input, plan, todosProjectPath));
9722
+ steps.push(prHandoffStep(input, plan, todosProjectPath, todosDbPath));
9665
9723
  }
9666
9724
  steps.push({
9667
9725
  id: "verifier",
@@ -9886,6 +9944,7 @@ function renderLifecycleBoundedTemplate(id, values) {
9886
9944
  taskTitle: values.taskTitle,
9887
9945
  taskDescription: values.taskDescription,
9888
9946
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
9947
+ todosDbPath: values.todosDbPath,
9889
9948
  triageAuthProfile: values.triageAuthProfile,
9890
9949
  plannerAuthProfile: values.plannerAuthProfile,
9891
9950
  triageAccount: accountVar(values.triageAccount, values.accountTool),
@@ -9950,6 +10009,7 @@ function renderBuiltinLoopTemplate(id, values) {
9950
10009
  taskTitle: values.taskTitle,
9951
10010
  taskDescription: values.taskDescription,
9952
10011
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
10012
+ todosDbPath: values.todosDbPath,
9953
10013
  eventId: values.eventId,
9954
10014
  eventType: values.eventType
9955
10015
  });
@@ -10397,7 +10457,8 @@ function writeRouteEvidence(kind, value, evidenceDir) {
10397
10457
  mkdirSync9(evidenceDir, { recursive: true, mode: 448 });
10398
10458
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
10399
10459
  const evidencePath = join9(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
10400
- writeFileSync5(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
10460
+ const encoded = scrubSecrets(JSON.stringify(scrubSecretsDeep(value), null, 2));
10461
+ writeFileSync5(evidencePath, encoded, { mode: 384, flag: "wx" });
10401
10462
  return evidencePath;
10402
10463
  }
10403
10464
  function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
@@ -10993,6 +11054,70 @@ function todosTaskRouteTemplateId(opts) {
10993
11054
  }
10994
11055
  return id;
10995
11056
  }
11057
+ function sourceCandidateRecords(data, metadata) {
11058
+ const records = [data, metadata];
11059
+ for (const record of [data, metadata]) {
11060
+ const sourceTask = objectField2(record.source_task) ?? objectField2(record.sourceTask);
11061
+ if (sourceTask)
11062
+ records.push(sourceTask);
11063
+ }
11064
+ return records;
11065
+ }
11066
+ function sourceStringField(records, keys) {
11067
+ for (const record of records) {
11068
+ for (const key of keys) {
11069
+ const value = stringField2(record[key]);
11070
+ if (value)
11071
+ return value;
11072
+ }
11073
+ }
11074
+ return;
11075
+ }
11076
+ function sourceBooleanField(records, keys) {
11077
+ for (const record of records) {
11078
+ for (const key of keys) {
11079
+ const value = record[key];
11080
+ if (typeof value === "boolean")
11081
+ return value;
11082
+ if (value === "true" || value === "1" || value === 1)
11083
+ return true;
11084
+ if (value === "false" || value === "0" || value === 0)
11085
+ return false;
11086
+ }
11087
+ }
11088
+ return;
11089
+ }
11090
+ function todosTaskSourceRef(data, metadata, taskId) {
11091
+ const records = sourceCandidateRecords(data, metadata);
11092
+ const sourceTaskKey = sourceStringField(records, ["source_task_key", "sourceTaskKey"]);
11093
+ const sourceStoreId = sourceStringField(records, ["source_store_id", "sourceStoreId"]);
11094
+ const sourceRepoPath = sourceStringField(records, ["source_repo_path", "sourceRepoPath"]);
11095
+ const sourceDbPath = sourceStringField(records, ["source_db_path", "sourceDbPath"]);
11096
+ const sourceSelectedByInput = sourceBooleanField(records, ["source_selected_by_input", "sourceSelectedByInput"]);
11097
+ const idempotencySource = sourceTaskKey ? "source_task_key" : sourceStoreId ? "source_store_id" : "legacy_task_id";
11098
+ return {
11099
+ sourceTaskKey,
11100
+ sourceStoreId,
11101
+ sourceRepoPath,
11102
+ sourceDbPath,
11103
+ sourceSelectedByInput,
11104
+ idempotencySource,
11105
+ idempotencyKey: sourceTaskKey ? `todos-task:${sourceTaskKey}` : sourceStoreId ? `todos-task:${sourceStoreId}:${taskId}` : `todos-task:${taskId}`
11106
+ };
11107
+ }
11108
+ function publicSourceTask(sourceTask) {
11109
+ if (!sourceTask.sourceTaskKey && !sourceTask.sourceStoreId && !sourceTask.sourceRepoPath && !sourceTask.sourceDbPath && sourceTask.sourceSelectedByInput === undefined) {
11110
+ return;
11111
+ }
11112
+ return {
11113
+ source_task_key: sourceTask.sourceTaskKey,
11114
+ source_store_id: sourceTask.sourceStoreId,
11115
+ source_repo_path: sourceTask.sourceRepoPath,
11116
+ source_db_path: sourceTask.sourceDbPath,
11117
+ source_selected_by_input: sourceTask.sourceSelectedByInput,
11118
+ idempotency_source: sourceTask.idempotencySource
11119
+ };
11120
+ }
10996
11121
  async function readEventEnvelopeInput(opts = {}) {
10997
11122
  const raw = opts.eventJson ?? (opts.eventFile ? readFileSync8(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
10998
11123
  const event = JSON.parse(raw);
@@ -11169,11 +11294,13 @@ function routeTodosTaskEvent(event, opts) {
11169
11294
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
11170
11295
  if (!taskId)
11171
11296
  throw new ValidationError("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
11297
+ const sourceTask = todosTaskSourceRef(data, metadata, taskId);
11298
+ const sourceTaskPublic = publicSourceTask(sourceTask);
11172
11299
  const eligibility = taskRouteEligibility(data, metadata);
11173
11300
  if (!eligibility.eligible) {
11174
11301
  return {
11175
11302
  kind: "skipped",
11176
- value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
11303
+ value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility, sourceTask: sourceTaskPublic },
11177
11304
  human: `skipped task ${taskId}: ${eligibility.reason}`
11178
11305
  };
11179
11306
  }
@@ -11188,11 +11315,11 @@ function routeTodosTaskEvent(event, opts) {
11188
11315
  "project_canonical_path",
11189
11316
  "cwd"
11190
11317
  ]);
11191
- const projectPath = dataProjectPath ?? metadataProjectPath ?? opts.projectPath ?? process.cwd();
11318
+ const projectPath = dataProjectPath ?? metadataProjectPath ?? sourceTask.sourceRepoPath ?? opts.projectPath ?? process.cwd();
11192
11319
  const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
11193
11320
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
11194
11321
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
11195
- const idempotencyKey = `todos-task:${taskId}`;
11322
+ const idempotencyKey = sourceTask.idempotencyKey;
11196
11323
  const idempotencySuffix = stableSuffix(idempotencyKey);
11197
11324
  const namePrefix = opts.namePrefix ?? "event:todos-task";
11198
11325
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -11205,7 +11332,7 @@ function routeTodosTaskEvent(event, opts) {
11205
11332
  const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11206
11333
  const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11207
11334
  const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
11208
- return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
11335
+ return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
11209
11336
  }
11210
11337
  } finally {
11211
11338
  store.close();
@@ -11268,12 +11395,14 @@ function routeTodosTaskEvent(event, opts) {
11268
11395
  prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
11269
11396
  eventId: event.id,
11270
11397
  eventType: event.type,
11271
- todosProjectPath: opts.todosProject
11398
+ todosProjectPath: sourceTask.sourceRepoPath ?? opts.todosProject,
11399
+ todosDbPath: sourceTask.sourceDbPath
11272
11400
  };
11273
11401
  const workflowContext = {
11274
11402
  name: workflowName,
11275
11403
  type: "todos-task-event-workflow",
11276
- event: event.id
11404
+ event: event.id,
11405
+ sourceTask: sourceTaskPublic
11277
11406
  };
11278
11407
  let workflowBody = templateId === TASK_LIFECYCLE_TEMPLATE_ID ? renderTaskLifecycleWorkflow(workflowInput) : renderTodosTaskWorkerVerifierWorkflow(workflowInput);
11279
11408
  workflowBody.name = workflowName;
@@ -11286,13 +11415,13 @@ function routeTodosTaskEvent(event, opts) {
11286
11415
  kind: "event",
11287
11416
  id: event.id,
11288
11417
  dedupeKey: idempotencyKey,
11289
- raw: { type: event.type, source: event.source, subject: event.subject }
11418
+ raw: { type: event.type, source: event.source, subject: event.subject, sourceTask: sourceTaskPublic }
11290
11419
  },
11291
11420
  subjectRef: {
11292
11421
  kind: "task",
11293
11422
  id: taskId,
11294
11423
  path: routeProjectPath,
11295
- raw: { title: taskTitle, description: taskDescription }
11424
+ raw: { title: taskTitle, description: taskDescription, sourceTask: sourceTaskPublic }
11296
11425
  },
11297
11426
  intent: "route",
11298
11427
  scope: {
@@ -11305,7 +11434,8 @@ function routeTodosTaskEvent(event, opts) {
11305
11434
  accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
11306
11435
  providerRouting: providerRoutingPublic(providerRouting),
11307
11436
  prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
11308
- concurrencyGroup: projectGroup ?? routeProjectPath
11437
+ concurrencyGroup: projectGroup ?? routeProjectPath,
11438
+ sourceTask: sourceTaskPublic
11309
11439
  },
11310
11440
  outputPolicy: {
11311
11441
  report: "always",
@@ -11330,9 +11460,10 @@ function routeTodosTaskEvent(event, opts) {
11330
11460
  humanSubject: `task ${taskId}`,
11331
11461
  valueExtras: {
11332
11462
  providerRouting: providerRoutingPublic(providerRouting),
11333
- prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined
11463
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
11464
+ sourceTask: sourceTaskPublic
11334
11465
  },
11335
- dedupeValueExtras: {}
11466
+ dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {}
11336
11467
  });
11337
11468
  }
11338
11469
  function routeGenericEvent(event, opts) {
@@ -11469,7 +11600,7 @@ function runLocalCommand(command, args, opts = {}) {
11469
11600
  encoding: "utf8",
11470
11601
  timeout: opts.timeoutMs ?? 30000,
11471
11602
  maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
11472
- env: process.env
11603
+ env: { ...process.env, ...opts.env }
11473
11604
  });
11474
11605
  return {
11475
11606
  ok: result.status === 0,
@@ -11536,8 +11667,66 @@ function taskField(task, keys) {
11536
11667
  }
11537
11668
  return;
11538
11669
  }
11539
- function taskListId(task) {
11540
- return taskField(task, ["task_list_id", "taskListId"]) ?? stringField2(task.task_list?.id);
11670
+ function booleanTaskField(task, keys) {
11671
+ for (const key of keys) {
11672
+ const value = task[key];
11673
+ if (typeof value === "boolean")
11674
+ return value;
11675
+ if (value === "true" || value === "1" || value === 1)
11676
+ return true;
11677
+ if (value === "false" || value === "0" || value === 0)
11678
+ return false;
11679
+ }
11680
+ return;
11681
+ }
11682
+ function normalizedSourceRoots(opts) {
11683
+ return listFromRepeatedOpts(opts.todosSourceRoot) ?? [];
11684
+ }
11685
+ function normalizedSourceStores(opts) {
11686
+ return listFromRepeatedOpts(opts.todosSourceStore) ?? [];
11687
+ }
11688
+ function normalizedSourceIncludes(opts) {
11689
+ return listFromRepeatedOpts(opts.todosSourceInclude) ?? [];
11690
+ }
11691
+ function normalizedSourceExcludes(opts) {
11692
+ return listFromRepeatedOpts(opts.todosSourceExclude) ?? [];
11693
+ }
11694
+ function hasTodosSourceOptions(opts) {
11695
+ return Boolean(normalizedSourceRoots(opts).length || normalizedSourceStores(opts).length || normalizedSourceIncludes(opts).length || normalizedSourceExcludes(opts).length);
11696
+ }
11697
+ function taskSourceIdentity(task, taskId) {
11698
+ const sourceTaskKey = taskField(task, ["source_task_key", "sourceTaskKey"]);
11699
+ const sourceStoreId = taskField(task, ["source_store_id", "sourceStoreId"]);
11700
+ const identity = sourceTaskKey ?? (sourceStoreId && taskId ? `${sourceStoreId}:${taskId}` : undefined);
11701
+ return {
11702
+ sourceTaskKey,
11703
+ sourceStoreId,
11704
+ sourceRepoPath: taskField(task, ["source_repo_path", "sourceRepoPath"]),
11705
+ sourceDbPath: taskField(task, ["source_db_path", "sourceDbPath"]),
11706
+ sourceSelectedByInput: booleanTaskField(task, ["source_selected_by_input", "sourceSelectedByInput"]),
11707
+ idempotencyIdentity: identity
11708
+ };
11709
+ }
11710
+ function publicSourceTaskIdentity(source) {
11711
+ if (!source.sourceTaskKey && !source.sourceStoreId && !source.sourceRepoPath && !source.sourceDbPath && source.sourceSelectedByInput === undefined) {
11712
+ return;
11713
+ }
11714
+ return {
11715
+ source_task_key: source.sourceTaskKey,
11716
+ source_store_id: source.sourceStoreId,
11717
+ source_repo_path: source.sourceRepoPath,
11718
+ source_db_path: source.sourceDbPath,
11719
+ source_selected_by_input: source.sourceSelectedByInput
11720
+ };
11721
+ }
11722
+ function taskListValues(task) {
11723
+ const values = [
11724
+ taskField(task, ["task_list_id", "taskListId", "task_list_slug", "taskListSlug", "task_list_name", "taskListName"]),
11725
+ stringField2(task.task_list?.id),
11726
+ stringField2(task.task_list?.slug),
11727
+ stringField2(task.task_list?.name)
11728
+ ].filter((value) => Boolean(value));
11729
+ return [...new Set(values)];
11541
11730
  }
11542
11731
  function taskProjectId(task) {
11543
11732
  return taskField(task, ["project_id", "projectId"]);
@@ -11551,12 +11740,14 @@ function taskProjectPath(task) {
11551
11740
  const metadata = objectField2(task.metadata) ?? {};
11552
11741
  return taskField(task, ["project_path", "projectPath"]) ?? taskEventField(metadata, ["project_path", "projectPath", "project_canonical_path"]) ?? taskDescriptionProjectPath(task) ?? taskField(task, ["working_dir", "workingDir", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "cwd"]);
11553
11742
  }
11554
- function taskDrainEvent(task) {
11743
+ function taskDrainEvent(task, opts = {}) {
11555
11744
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
11556
11745
  if (!taskId)
11557
11746
  throw new Error("todos ready returned a task without an id");
11558
11747
  const metadata = objectField2(task.metadata) ?? {};
11559
11748
  const workingDir = taskProjectPath(task);
11749
+ const sourceIdentity = taskSourceIdentity(task, taskId);
11750
+ const sourceTask = publicSourceTaskIdentity(sourceIdentity);
11560
11751
  const data = {
11561
11752
  ...task,
11562
11753
  id: taskId,
@@ -11566,14 +11757,21 @@ function taskDrainEvent(task) {
11566
11757
  tags: tagsFromValue2(task.tags),
11567
11758
  metadata
11568
11759
  };
11760
+ if (opts.sourceRouteEligible) {
11761
+ data.route_enabled = true;
11762
+ data.route_enabled_by = "source_route_state";
11763
+ }
11569
11764
  if (workingDir) {
11570
11765
  data.working_dir = workingDir;
11571
11766
  data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
11572
11767
  data.cwd = taskField(task, ["cwd"]) ?? workingDir;
11573
11768
  }
11769
+ if (sourceTask)
11770
+ data.source_task = sourceTask;
11771
+ const eventId = sourceIdentity.idempotencyIdentity ? `drain-todos-task-${slugSegment2(taskId, "task")}-${stableSuffix(sourceIdentity.idempotencyIdentity)}` : `drain-todos-task-${taskId}`;
11574
11772
  const time = new Date().toISOString();
11575
11773
  return {
11576
- id: `drain-todos-task-${taskId}`,
11774
+ id: eventId,
11577
11775
  type: "task.created",
11578
11776
  source: "@hasna/todos",
11579
11777
  subject: taskId,
@@ -11584,6 +11782,8 @@ function taskDrainEvent(task) {
11584
11782
  metadata: {
11585
11783
  ...metadata,
11586
11784
  ...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
11785
+ ...sourceTask,
11786
+ ...sourceTask ? { source_task: sourceTask } : {},
11587
11787
  drained_by: "@hasna/loops",
11588
11788
  drained_from: "todos ready"
11589
11789
  }
@@ -11592,6 +11792,7 @@ function taskDrainEvent(task) {
11592
11792
  function compactDrainResult(result) {
11593
11793
  const value = result.value;
11594
11794
  const event = objectField2(value.event);
11795
+ const sourceTask = objectField2(value.sourceTask) ?? objectField2(objectField2(event?.metadata)?.source_task) ?? objectField2(objectField2(event?.data)?.source_task);
11595
11796
  const loop = objectField2(value.loop);
11596
11797
  const workflow = objectField2(value.workflow);
11597
11798
  const throttle = objectField2(value.throttle);
@@ -11608,26 +11809,75 @@ function compactDrainResult(result) {
11608
11809
  workflowId: stringField2(workflow?.id),
11609
11810
  workflowName: stringField2(workflow?.name),
11610
11811
  providerRouting,
11812
+ sourceTask,
11611
11813
  requeue,
11612
11814
  queuedAtSource: value.queuedAtSource
11613
11815
  };
11614
11816
  }
11615
- function loadReadyTodosTasks(opts, scanLimit) {
11616
- const todosProject = opts.todosProject ?? defaultLoopsProject();
11617
- const args = ["--project", todosProject, "--json", "ready", "--limit", String(scanLimit)];
11618
- const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
11619
- if (!result.ok)
11620
- throw new Error(result.stderr || result.error || "todos ready failed");
11621
- let parsed;
11817
+ function numberField2(value) {
11818
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
11819
+ }
11820
+ function parseTodosReadyJson(stdout) {
11622
11821
  try {
11623
- parsed = JSON.parse(result.stdout || "[]");
11822
+ return JSON.parse(stdout || "[]");
11624
11823
  } catch (error) {
11625
11824
  const message = error instanceof Error ? error.message : String(error);
11626
- throw new Error(`failed to parse todos ready --json output (${result.stdout.length} bytes): ${message}`);
11627
- }
11628
- if (!Array.isArray(parsed))
11629
- throw new Error("todos ready --json returned a non-array value");
11630
- return parsed;
11825
+ throw new Error(`failed to parse todos ready --json output (${stdout.length} bytes): ${message}`);
11826
+ }
11827
+ }
11828
+ function loadReadyTodosTasks(opts, scanLimit, sourceMode) {
11829
+ const sourceRoots = normalizedSourceRoots(opts);
11830
+ const sourceStores = normalizedSourceStores(opts);
11831
+ const sourceIncludes = normalizedSourceIncludes(opts);
11832
+ const sourceExcludes = normalizedSourceExcludes(opts);
11833
+ if (sourceMode && !sourceRoots.length && !sourceStores.length) {
11834
+ throw new ValidationError("source discovery requires at least one --todos-source-root or --todos-source-store");
11835
+ }
11836
+ const args = sourceMode ? ["--json", "ready"] : ["--project", opts.todosProject ?? defaultLoopsProject(), "--json", "ready"];
11837
+ if (sourceMode) {
11838
+ for (const root of sourceRoots)
11839
+ args.push("--source-root", root);
11840
+ for (const store of sourceStores)
11841
+ args.push("--source-store", store);
11842
+ for (const pattern of sourceIncludes)
11843
+ args.push("--include", pattern);
11844
+ for (const pattern of sourceExcludes)
11845
+ args.push("--exclude", pattern);
11846
+ }
11847
+ args.push("--limit", String(scanLimit));
11848
+ const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
11849
+ if (!result.ok)
11850
+ throw new Error(result.stderr || result.error || "todos ready failed");
11851
+ const parsed = parseTodosReadyJson(result.stdout);
11852
+ if (!sourceMode) {
11853
+ if (!Array.isArray(parsed))
11854
+ throw new Error("todos ready --json returned a non-array value");
11855
+ return { tasks: parsed, sourceMode, sourceRoots, sourceStores, sourceIncludes, sourceExcludes };
11856
+ }
11857
+ const response = objectField2(parsed);
11858
+ if (!response)
11859
+ throw new Error("todos ready --json source discovery returned a non-object value");
11860
+ if (response.schema_version !== "todos.task_route_sources.v1") {
11861
+ throw new Error(`todos ready source discovery returned unsupported schema_version: ${String(response.schema_version)}`);
11862
+ }
11863
+ if (!Array.isArray(response.candidates))
11864
+ throw new Error("todos ready source discovery returned missing candidates array");
11865
+ return {
11866
+ tasks: response.candidates,
11867
+ sourceMode,
11868
+ sourceRoots,
11869
+ sourceStores,
11870
+ sourceIncludes,
11871
+ sourceExcludes,
11872
+ sourceDiscovery: {
11873
+ schemaVersion: "todos.task_route_sources.v1",
11874
+ stores: Array.isArray(response.stores) ? response.stores : undefined,
11875
+ errors: Array.isArray(response.errors) ? response.errors : undefined,
11876
+ totalCandidateCount: numberField2(response.total_candidate_count),
11877
+ returnedCandidateCount: numberField2(response.returned_candidate_count),
11878
+ truncated: typeof response.truncated === "boolean" ? response.truncated : undefined
11879
+ }
11880
+ };
11631
11881
  }
11632
11882
  function resolveTaskListFilter(todosProject, filter) {
11633
11883
  const wanted = filter?.trim();
@@ -11643,7 +11893,7 @@ function resolveTaskListFilter(todosProject, filter) {
11643
11893
  function taskMatchesDrainFilters(task, filters) {
11644
11894
  if (filters.projectId && taskProjectId(task) !== filters.projectId)
11645
11895
  return false;
11646
- if (filters.taskListId && taskListId(task) !== filters.taskListId)
11896
+ if (filters.taskList && !taskListValues(task).includes(filters.taskList))
11647
11897
  return false;
11648
11898
  if (filters.projectPathPrefix) {
11649
11899
  const path = taskProjectPath(task);
@@ -11681,19 +11931,77 @@ function skippedDrainTask(task, event, reason, extra = {}) {
11681
11931
  function isSkippableDrainRouteError(message) {
11682
11932
  return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
11683
11933
  }
11684
- function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
11934
+ function sourceRouteState(task) {
11935
+ return objectField2(task.route_state) ?? objectField2(task.routeState);
11936
+ }
11937
+ function sourceRouteReason(routeState) {
11938
+ const reasons = routeState?.reasons;
11939
+ const firstReason = Array.isArray(reasons) ? stringField2(reasons[0]) : undefined;
11940
+ return stringField2(routeState?.reason) ?? stringField2(routeState?.eligibility_reason) ?? stringField2(routeState?.eligibilityReason) ?? firstReason;
11941
+ }
11942
+ function sourceRouteEligibility(task) {
11943
+ const routeState = sourceRouteState(task);
11944
+ if (routeState?.eligible === true)
11945
+ return { eligible: true, routeState };
11946
+ const reason = sourceRouteReason(routeState) ?? "source route_state.eligible is not true";
11947
+ return { eligible: false, reason, routeState };
11948
+ }
11949
+ function sourceTaskMutationTarget(todosProject, task, opts = {}) {
11950
+ const source = taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"]));
11951
+ if (source.sourceDbPath) {
11952
+ return {
11953
+ argsPrefix: source.sourceRepoPath ? ["--project", source.sourceRepoPath] : [],
11954
+ env: {
11955
+ TODOS_DB_PATH: source.sourceDbPath,
11956
+ HASNA_TODOS_DB_PATH: source.sourceDbPath
11957
+ },
11958
+ project: source.sourceRepoPath,
11959
+ sourceDbPath: source.sourceDbPath,
11960
+ sourceRepoPath: source.sourceRepoPath,
11961
+ sourceStoreId: source.sourceStoreId
11962
+ };
11963
+ }
11964
+ if (source.sourceRepoPath) {
11965
+ return {
11966
+ argsPrefix: ["--project", source.sourceRepoPath],
11967
+ project: source.sourceRepoPath,
11968
+ sourceRepoPath: source.sourceRepoPath,
11969
+ sourceStoreId: source.sourceStoreId
11970
+ };
11971
+ }
11972
+ if (opts.sourceMode)
11973
+ return;
11974
+ const project = todosProject ?? defaultLoopsProject();
11975
+ return { argsPrefix: ["--project", project], project };
11976
+ }
11977
+ function markInvalidDrainTaskNonRouteable(todosProject, task, reason, opts = {}) {
11685
11978
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
11686
11979
  if (!taskId)
11687
11980
  return { attempted: false, reason: "task id missing" };
11688
11981
  const comment = `OpenLoops route blocked for task ${taskId}: ${reason}. Added no-auto and removed auto:route so route drains do not repeatedly route this task until its project path is fixed.`;
11689
- const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
11690
- const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
11691
- const untagResult = runLocalCommand("todos", ["--project", todosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
11982
+ const target = sourceTaskMutationTarget(todosProject, task, { sourceMode: opts.sourceMode });
11983
+ if (!target) {
11984
+ return {
11985
+ attempted: false,
11986
+ taskId,
11987
+ reason: "source task missing source_db_path or source_repo_path; refusing to update router/default Todos store"
11988
+ };
11989
+ }
11990
+ const commentResult = runLocalCommand("todos", [...target.argsPrefix, "comment", taskId, comment], { timeoutMs: 30000, env: target.env });
11991
+ const tagResult = runLocalCommand("todos", [...target.argsPrefix, "tag", taskId, "no-auto"], { timeoutMs: 30000, env: target.env });
11992
+ const untagResult = runLocalCommand("todos", [...target.argsPrefix, "untag", taskId, "auto:route"], { timeoutMs: 30000, env: target.env });
11692
11993
  const ok = commentResult.ok && tagResult.ok && untagResult.ok;
11693
11994
  return {
11694
11995
  ok,
11695
11996
  attempted: true,
11696
11997
  taskId,
11998
+ target: {
11999
+ project: target.project,
12000
+ source_db_path: target.sourceDbPath,
12001
+ source_repo_path: target.sourceRepoPath,
12002
+ source_store_id: target.sourceStoreId,
12003
+ used_db_env: Boolean(target.sourceDbPath)
12004
+ },
11697
12005
  error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
11698
12006
  comment: todosMutationSummary(commentResult),
11699
12007
  tagNoAuto: todosMutationSummary(tagResult),
@@ -11702,17 +12010,19 @@ function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
11702
12010
  }
11703
12011
  function drainTodosTaskRoutes(opts) {
11704
12012
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
11705
- const todosProject = opts.todosProject ?? defaultLoopsProject();
12013
+ const sourceMode = hasTodosSourceOptions(opts);
12014
+ const todosProject = sourceMode ? opts.todosProject : opts.todosProject ?? defaultLoopsProject();
11706
12015
  const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
11707
- const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
12016
+ const taskListFilter = sourceMode ? opts.taskList?.trim() : resolveTaskListFilter(todosProject ?? defaultLoopsProject(), opts.taskList);
11708
12017
  const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
11709
12018
  const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
11710
12019
  const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
11711
12020
  const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
11712
- const ready = loadReadyTodosTasks(opts, scanLimit);
12021
+ const loaded = loadReadyTodosTasks(opts, scanLimit, sourceMode);
12022
+ const ready = loaded.tasks;
11713
12023
  const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
11714
12024
  projectId: opts.todosProjectId,
11715
- taskListId: taskListFilter,
12025
+ taskList: taskListFilter,
11716
12026
  projectPathPrefix: opts.projectPathPrefix,
11717
12027
  tags: requiredTags
11718
12028
  }));
@@ -11725,13 +12035,25 @@ function drainTodosTaskRoutes(opts) {
11725
12035
  let event;
11726
12036
  let result;
11727
12037
  try {
11728
- event = taskDrainEvent(task);
12038
+ let sourceEligibility;
12039
+ if (sourceMode) {
12040
+ sourceEligibility = sourceRouteEligibility(task);
12041
+ if (!sourceEligibility.eligible) {
12042
+ result = skippedDrainTask(task, undefined, sourceEligibility.reason ?? "source route_state.eligible is not true", {
12043
+ sourceRouteState: sourceEligibility.routeState,
12044
+ sourceTask: publicSourceTaskIdentity(taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"])))
12045
+ });
12046
+ results.push(result);
12047
+ continue;
12048
+ }
12049
+ }
12050
+ event = taskDrainEvent(task, { sourceRouteEligible: sourceEligibility?.eligible === true });
11729
12051
  result = routeTodosTaskEvent(event, opts);
11730
12052
  } catch (error) {
11731
12053
  const message = error instanceof Error ? error.message : String(error);
11732
12054
  if (!isSkippableDrainRouteError(message))
11733
12055
  throw error;
11734
- const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
12056
+ const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message, { sourceMode });
11735
12057
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
11736
12058
  }
11737
12059
  results.push(result);
@@ -11741,6 +12063,12 @@ function drainTodosTaskRoutes(opts) {
11741
12063
  const report = {
11742
12064
  drainedAt: new Date().toISOString(),
11743
12065
  todosProject,
12066
+ sourceMode,
12067
+ sourceRoots: loaded.sourceRoots,
12068
+ sourceStores: loaded.sourceStores,
12069
+ sourceIncludes: loaded.sourceIncludes,
12070
+ sourceExcludes: loaded.sourceExcludes,
12071
+ sourceDiscovery: loaded.sourceDiscovery,
11744
12072
  templateId: todosTaskRouteTemplateId(opts),
11745
12073
  todosProjectId: opts.todosProjectId,
11746
12074
  taskList: opts.taskList,
@@ -11753,14 +12081,14 @@ function drainTodosTaskRoutes(opts) {
11753
12081
  scanned: ready.length,
11754
12082
  candidates: candidates.length,
11755
12083
  filteredCandidates: filteredCandidates.length,
11756
- scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
12084
+ scanExhausted: sourceMode ? Boolean(loaded.sourceDiscovery?.truncated) || ready.length >= scanLimit && filteredCandidates.length < candidateLimit : ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
11757
12085
  considered: results.length,
11758
12086
  created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
11759
12087
  deduped: results.filter((result) => result.kind === "deduped").length,
11760
12088
  throttled: results.filter((result) => result.kind === "throttled").length,
11761
12089
  skipped: results.filter((result) => result.kind === "skipped").length,
11762
12090
  maxDispatch,
11763
- source: "todos ready",
12091
+ source: sourceMode ? "todos ready source discovery" : "todos ready",
11764
12092
  dryRun: Boolean(opts.dryRun),
11765
12093
  results: results.map((result) => ({ kind: result.kind, ...result.value }))
11766
12094
  };
@@ -11768,6 +12096,12 @@ function drainTodosTaskRoutes(opts) {
11768
12096
  const value = opts.compact ? {
11769
12097
  drainedAt: report.drainedAt,
11770
12098
  todosProject: report.todosProject,
12099
+ sourceMode: report.sourceMode,
12100
+ sourceRoots: report.sourceRoots,
12101
+ sourceStores: report.sourceStores,
12102
+ sourceIncludes: report.sourceIncludes,
12103
+ sourceExcludes: report.sourceExcludes,
12104
+ sourceDiscovery: report.sourceDiscovery,
11771
12105
  templateId: report.templateId,
11772
12106
  todosProjectId: report.todosProjectId,
11773
12107
  taskList: report.taskList,
@@ -11793,7 +12127,7 @@ function drainTodosTaskRoutes(opts) {
11793
12127
  results: results.map(compactDrainResult)
11794
12128
  } : { ...report, evidencePath };
11795
12129
  return {
11796
- value,
12130
+ value: scrubSecretsDeep(value),
11797
12131
  human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`
11798
12132
  };
11799
12133
  }
@@ -12071,6 +12405,34 @@ var EVENT_INPUT_OPTION_SPECS = [
12071
12405
  { flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
12072
12406
  ];
12073
12407
  var DRAIN_FILTER_OPTION_SPECS = [
12408
+ {
12409
+ flags: "--todos-source-root <path>",
12410
+ key: "todosSourceRoot",
12411
+ kind: "repeat",
12412
+ description: "scan todos route candidates discovered under this source root; may be repeated",
12413
+ serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceRoot)
12414
+ },
12415
+ {
12416
+ flags: "--todos-source-store <path>",
12417
+ key: "todosSourceStore",
12418
+ kind: "repeat",
12419
+ description: "scan this explicit todos source store path; may be repeated",
12420
+ serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceStore)
12421
+ },
12422
+ {
12423
+ flags: "--todos-source-include <pattern>",
12424
+ key: "todosSourceInclude",
12425
+ kind: "repeat",
12426
+ description: "include source discovery entries matching this pattern; may be repeated",
12427
+ serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceInclude)
12428
+ },
12429
+ {
12430
+ flags: "--todos-source-exclude <pattern>",
12431
+ key: "todosSourceExclude",
12432
+ kind: "repeat",
12433
+ description: "exclude source discovery entries matching this pattern; may be repeated",
12434
+ serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceExclude)
12435
+ },
12074
12436
  { flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
12075
12437
  { flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
12076
12438
  { flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },