@hasna/loops 0.4.0 → 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(),
@@ -3964,6 +3981,273 @@ class Store {
3964
3981
  this.db.close();
3965
3982
  }
3966
3983
  }
3984
+ // package.json
3985
+ var package_default = {
3986
+ name: "@hasna/loops",
3987
+ version: "0.4.2",
3988
+ description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
3989
+ type: "module",
3990
+ main: "dist/index.js",
3991
+ types: "dist/index.d.ts",
3992
+ bin: {
3993
+ loops: "dist/cli/index.js",
3994
+ "loops-daemon": "dist/daemon/index.js",
3995
+ "loops-api": "dist/api/index.js",
3996
+ "loops-runner": "dist/runner/index.js",
3997
+ "loops-mcp": "dist/mcp/index.js"
3998
+ },
3999
+ exports: {
4000
+ ".": {
4001
+ types: "./dist/index.d.ts",
4002
+ import: "./dist/index.js"
4003
+ },
4004
+ "./sdk": {
4005
+ types: "./dist/sdk/index.d.ts",
4006
+ import: "./dist/sdk/index.js"
4007
+ },
4008
+ "./mcp": {
4009
+ types: "./dist/mcp/index.d.ts",
4010
+ import: "./dist/mcp/index.js"
4011
+ },
4012
+ "./api": {
4013
+ types: "./dist/api/index.d.ts",
4014
+ import: "./dist/api/index.js"
4015
+ },
4016
+ "./runner": {
4017
+ types: "./dist/runner/index.d.ts",
4018
+ import: "./dist/runner/index.js"
4019
+ },
4020
+ "./mode": {
4021
+ types: "./dist/lib/mode.d.ts",
4022
+ import: "./dist/lib/mode.js"
4023
+ },
4024
+ "./storage": {
4025
+ types: "./dist/lib/store.d.ts",
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"
4043
+ }
4044
+ },
4045
+ files: [
4046
+ "dist",
4047
+ "README.md",
4048
+ "CHANGELOG.md",
4049
+ "docs",
4050
+ "LICENSE"
4051
+ ],
4052
+ scripts: {
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",
4054
+ "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
4055
+ typecheck: "tsc --noEmit",
4056
+ test: "bun test",
4057
+ "test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
4058
+ prepare: "test -d dist || bun run build",
4059
+ "dev:cli": "bun run src/cli/index.ts",
4060
+ "dev:daemon": "bun run src/daemon/index.ts",
4061
+ prepublishOnly: "bun run build && bun run test:boundary"
4062
+ },
4063
+ keywords: [
4064
+ "loops",
4065
+ "scheduler",
4066
+ "daemon",
4067
+ "agents",
4068
+ "claude",
4069
+ "cursor",
4070
+ "codewith",
4071
+ "opencode",
4072
+ "cli"
4073
+ ],
4074
+ repository: {
4075
+ type: "git",
4076
+ url: "git+https://github.com/hasna/loops.git"
4077
+ },
4078
+ homepage: "https://github.com/hasna/loops#readme",
4079
+ bugs: {
4080
+ url: "https://github.com/hasna/loops/issues"
4081
+ },
4082
+ author: "Hasna <andrei@hasna.com>",
4083
+ license: "Apache-2.0",
4084
+ engines: {
4085
+ bun: ">=1.0.0"
4086
+ },
4087
+ dependencies: {
4088
+ "@hasna/events": "^0.1.9",
4089
+ "@modelcontextprotocol/sdk": "^1.29.0",
4090
+ "@openrouter/ai-sdk-provider": "2.9.1",
4091
+ ai: "6.0.204",
4092
+ commander: "^13.1.0",
4093
+ zod: "4.4.3"
4094
+ },
4095
+ optionalDependencies: {
4096
+ "@hasna/machines": "0.0.49"
4097
+ },
4098
+ devDependencies: {
4099
+ "@types/bun": "latest",
4100
+ typescript: "^5.7.3"
4101
+ },
4102
+ publishConfig: {
4103
+ registry: "https://registry.npmjs.org",
4104
+ access: "public"
4105
+ }
4106
+ };
4107
+
4108
+ // src/lib/version.ts
4109
+ function packageVersion() {
4110
+ if (typeof package_default.version !== "string" || package_default.version.trim() === "")
4111
+ throw new Error("package.json version is missing");
4112
+ return package_default.version;
4113
+ }
4114
+
4115
+ // src/lib/mode.ts
4116
+ var LOOP_DEPLOYMENT_MODES = ["local", "self_hosted", "cloud"];
4117
+ var MODE_ENV_KEYS = ["LOOPS_MODE", "HASNA_LOOPS_MODE"];
4118
+ var API_URL_ENV_KEYS = ["LOOPS_API_URL", "HASNA_LOOPS_API_URL"];
4119
+ var CLOUD_API_URL_ENV_KEYS = ["LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"];
4120
+ var DATABASE_URL_ENV_KEYS = ["LOOPS_DATABASE_URL", "HASNA_LOOPS_DATABASE_URL"];
4121
+ var API_TOKEN_ENV_KEYS = ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN"];
4122
+ var CLOUD_TOKEN_ENV_KEYS = ["LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"];
4123
+ var TOKEN_ENV_KEYS = [...API_TOKEN_ENV_KEYS, ...CLOUD_TOKEN_ENV_KEYS];
4124
+ function envValue(env, keys) {
4125
+ for (const key of keys) {
4126
+ const value = env[key]?.trim();
4127
+ if (value)
4128
+ return { key, value };
4129
+ }
4130
+ return;
4131
+ }
4132
+ function normalizeLoopDeploymentMode(value) {
4133
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
4134
+ if (normalized === "local")
4135
+ return "local";
4136
+ if (normalized === "self_hosted" || normalized === "selfhosted")
4137
+ return "self_hosted";
4138
+ if (normalized === "cloud" || normalized === "saas")
4139
+ return "cloud";
4140
+ throw new Error(`unsupported OpenLoops deployment mode "${value}"; expected local, self_hosted, or cloud`);
4141
+ }
4142
+ function resolveLoopDeploymentMode(env = process.env) {
4143
+ const explicitMode = envValue(env, MODE_ENV_KEYS);
4144
+ if (explicitMode) {
4145
+ return {
4146
+ deploymentMode: normalizeLoopDeploymentMode(explicitMode.value),
4147
+ source: explicitMode.key
4148
+ };
4149
+ }
4150
+ const cloudApiUrl = envValue(env, CLOUD_API_URL_ENV_KEYS);
4151
+ if (cloudApiUrl)
4152
+ return { deploymentMode: "cloud", source: cloudApiUrl.key };
4153
+ const apiUrl = envValue(env, API_URL_ENV_KEYS);
4154
+ if (apiUrl)
4155
+ return { deploymentMode: "self_hosted", source: apiUrl.key };
4156
+ const databaseUrl = envValue(env, DATABASE_URL_ENV_KEYS);
4157
+ if (databaseUrl)
4158
+ return { deploymentMode: "self_hosted", source: databaseUrl.key };
4159
+ return { deploymentMode: "local", source: "default" };
4160
+ }
4161
+ function loopControlPlaneConfig(env = process.env) {
4162
+ return {
4163
+ apiUrl: envValue(env, API_URL_ENV_KEYS)?.value,
4164
+ cloudApiUrl: envValue(env, CLOUD_API_URL_ENV_KEYS)?.value,
4165
+ databaseUrlPresent: Boolean(envValue(env, DATABASE_URL_ENV_KEYS)),
4166
+ apiAuthTokenPresent: Boolean(envValue(env, API_TOKEN_ENV_KEYS)),
4167
+ cloudAuthTokenPresent: Boolean(envValue(env, CLOUD_TOKEN_ENV_KEYS)),
4168
+ authTokenPresent: Boolean(envValue(env, TOKEN_ENV_KEYS))
4169
+ };
4170
+ }
4171
+ function sourceOfTruthForMode(mode) {
4172
+ if (mode === "local")
4173
+ return "local_sqlite";
4174
+ if (mode === "self_hosted")
4175
+ return "self_hosted_control_plane";
4176
+ return "cloud_control_plane";
4177
+ }
4178
+ function displayControlPlaneUrl(value) {
4179
+ if (!value)
4180
+ return;
4181
+ try {
4182
+ const url = new URL(value);
4183
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, "");
4184
+ return `${url.origin}${path}`;
4185
+ } catch {
4186
+ return "[invalid-url]";
4187
+ }
4188
+ }
4189
+ function buildDeploymentStatus(opts = {}) {
4190
+ const env = opts.env ?? process.env;
4191
+ const active = resolveLoopDeploymentMode(env);
4192
+ const deploymentMode = opts.perspective ?? active.deploymentMode;
4193
+ const config = loopControlPlaneConfig(env);
4194
+ const rawApiUrl = deploymentMode === "cloud" ? config.cloudApiUrl : config.apiUrl;
4195
+ const apiUrl = displayControlPlaneUrl(rawApiUrl);
4196
+ const controlPlaneKind = deploymentMode === "local" ? "none" : deploymentMode;
4197
+ const deploymentAuthTokenPresent = deploymentMode === "cloud" ? config.cloudAuthTokenPresent : deploymentMode === "self_hosted" ? config.apiAuthTokenPresent : false;
4198
+ const controlPlaneConfigured = deploymentMode === "local" ? false : deploymentMode === "self_hosted" ? Boolean(config.apiUrl || config.databaseUrlPresent) : Boolean(config.cloudApiUrl && deploymentAuthTokenPresent);
4199
+ const warnings = [];
4200
+ if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
4201
+ warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
4202
+ }
4203
+ if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
4204
+ warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
4205
+ }
4206
+ if (deploymentMode === "cloud" && !config.cloudApiUrl) {
4207
+ warnings.push("cloud mode is a public contract until LOOPS_CLOUD_API_URL is configured");
4208
+ }
4209
+ if (deploymentMode === "cloud" && config.cloudApiUrl && !deploymentAuthTokenPresent) {
4210
+ warnings.push("cloud mode needs LOOPS_CLOUD_TOKEN or HASNA_LOOPS_CLOUD_TOKEN before it can become ready");
4211
+ }
4212
+ if (opts.perspective && opts.perspective !== active.deploymentMode) {
4213
+ warnings.push(`active deployment mode is ${active.deploymentMode}; this is the ${opts.perspective} perspective`);
4214
+ }
4215
+ return {
4216
+ packageVersion: packageVersion(),
4217
+ deploymentMode,
4218
+ activeDeploymentMode: active.deploymentMode,
4219
+ active: deploymentMode === active.deploymentMode,
4220
+ deploymentModeSource: active.source,
4221
+ sourceOfTruth: sourceOfTruthForMode(deploymentMode),
4222
+ localStore: {
4223
+ role: deploymentMode === "local" ? "authoritative" : "cache_and_spool"
4224
+ },
4225
+ controlPlane: {
4226
+ kind: controlPlaneKind,
4227
+ configured: controlPlaneConfigured,
4228
+ apiUrl,
4229
+ databaseUrlPresent: config.databaseUrlPresent,
4230
+ authTokenPresent: deploymentAuthTokenPresent
4231
+ },
4232
+ runner: {
4233
+ required: deploymentMode !== "local",
4234
+ role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
4235
+ },
4236
+ warnings
4237
+ };
4238
+ }
4239
+ function deploymentStatusLine(status) {
4240
+ const configured = status.controlPlane.kind === "none" ? "none" : String(status.controlPlane.configured);
4241
+ const active = status.active ? "active" : `active=${status.activeDeploymentMode}`;
4242
+ return [
4243
+ `deploymentMode=${status.deploymentMode}`,
4244
+ active,
4245
+ `source=${status.deploymentModeSource}`,
4246
+ `truth=${status.sourceOfTruth}`,
4247
+ `local=${status.localStore.role}`,
4248
+ `control_plane=${configured}`
4249
+ ].join(" ");
4250
+ }
3967
4251
 
3968
4252
  // src/cli/index.ts
3969
4253
  import { randomUUID as randomUUID2 } from "crypto";
@@ -4029,11 +4313,12 @@ function publicLoop(loop) {
4029
4313
  target
4030
4314
  };
4031
4315
  }
4032
- function publicRun(run, showOutput = false) {
4316
+ function publicRun(run, showOutput = false, opts = {}) {
4033
4317
  return {
4034
4318
  ...run,
4035
4319
  stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
4036
- 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
4037
4322
  };
4038
4323
  }
4039
4324
  function publicExecutorResult(result, showOutput = false) {
@@ -7701,6 +7986,7 @@ function runDoctor(store) {
7701
7986
 
7702
7987
  // src/lib/hygiene.ts
7703
7988
  import { basename as basename3 } from "path";
7989
+ import { homedir as homedir3 } from "os";
7704
7990
  var PROVIDER_TOKENS = new Set([
7705
7991
  "codewith",
7706
7992
  "claude",
@@ -7715,11 +8001,14 @@ var PROVIDER_TOKENS = new Set([
7715
8001
  var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
7716
8002
  var CADENCE_SUFFIX_TOKENS = new Set(["hourly", "daily", "weekly", "monthly"]);
7717
8003
  var CADENCE_SUFFIX_PATTERN = /^(?:every-?)?\d+(?:s|m|h|d|w)$/;
8004
+ function userHome() {
8005
+ return process.env.HOME || homedir3();
8006
+ }
7718
8007
  function slugify(value) {
7719
8008
  return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
7720
8009
  }
7721
8010
  function repoSlugFromCwd(cwd) {
7722
- if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
8011
+ if (!cwd || cwd === userHome())
7723
8012
  return "";
7724
8013
  if (cwd.includes("/.hasna/loops/"))
7725
8014
  return "";
@@ -7902,7 +8191,7 @@ function commandText(loop) {
7902
8191
  return [loop.target.command, ...loop.target.args ?? []].join(" ");
7903
8192
  }
7904
8193
  function scriptNeedles(scriptsDir) {
7905
- const home = process.env.HOME ?? "/home/hasna";
8194
+ const home = userHome();
7906
8195
  const normalized = scriptsDir.replace(/\/+$/g, "");
7907
8196
  const values = [
7908
8197
  normalized,
@@ -7920,7 +8209,7 @@ function scriptNeedles(scriptsDir) {
7920
8209
  return [...new Set(values)];
7921
8210
  }
7922
8211
  function buildScriptInventoryReport(store, opts = {}) {
7923
- const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
8212
+ const scriptsDir = opts.scriptsDir ?? `${userHome()}/.hasna/loops/scripts`;
7924
8213
  const needles = scriptNeedles(scriptsDir);
7925
8214
  const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
7926
8215
  const scriptBacked = loops.map((loop) => {
@@ -7947,110 +8236,11 @@ function buildScriptInventoryReport(store, opts = {}) {
7947
8236
  loops: scriptBacked
7948
8237
  };
7949
8238
  }
7950
- // package.json
7951
- var package_default = {
7952
- name: "@hasna/loops",
7953
- version: "0.4.0",
7954
- description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7955
- type: "module",
7956
- main: "dist/index.js",
7957
- types: "dist/index.d.ts",
7958
- bin: {
7959
- loops: "dist/cli/index.js",
7960
- "loops-daemon": "dist/daemon/index.js",
7961
- "loops-mcp": "dist/mcp/index.js"
7962
- },
7963
- exports: {
7964
- ".": {
7965
- types: "./dist/index.d.ts",
7966
- import: "./dist/index.js"
7967
- },
7968
- "./sdk": {
7969
- types: "./dist/sdk/index.d.ts",
7970
- import: "./dist/sdk/index.js"
7971
- },
7972
- "./mcp": {
7973
- types: "./dist/mcp/index.d.ts",
7974
- import: "./dist/mcp/index.js"
7975
- },
7976
- "./storage": {
7977
- types: "./dist/lib/store.d.ts",
7978
- import: "./dist/lib/store.js"
7979
- }
7980
- },
7981
- files: [
7982
- "dist",
7983
- "README.md",
7984
- "CHANGELOG.md",
7985
- "docs",
7986
- "LICENSE"
7987
- ],
7988
- scripts: {
7989
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
7990
- "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
7991
- typecheck: "tsc --noEmit",
7992
- test: "bun test",
7993
- prepare: "test -d dist || bun run build",
7994
- "dev:cli": "bun run src/cli/index.ts",
7995
- "dev:daemon": "bun run src/daemon/index.ts",
7996
- prepublishOnly: "bun run build"
7997
- },
7998
- keywords: [
7999
- "loops",
8000
- "scheduler",
8001
- "daemon",
8002
- "agents",
8003
- "claude",
8004
- "cursor",
8005
- "codewith",
8006
- "opencode",
8007
- "cli"
8008
- ],
8009
- repository: {
8010
- type: "git",
8011
- url: "git+https://github.com/hasna/loops.git"
8012
- },
8013
- homepage: "https://github.com/hasna/loops#readme",
8014
- bugs: {
8015
- url: "https://github.com/hasna/loops/issues"
8016
- },
8017
- author: "Hasna <andrei@hasna.com>",
8018
- license: "Apache-2.0",
8019
- engines: {
8020
- bun: ">=1.0.0"
8021
- },
8022
- dependencies: {
8023
- "@hasna/events": "^0.1.9",
8024
- "@modelcontextprotocol/sdk": "^1.29.0",
8025
- "@openrouter/ai-sdk-provider": "2.9.1",
8026
- ai: "6.0.204",
8027
- commander: "^13.1.0",
8028
- zod: "4.4.3"
8029
- },
8030
- optionalDependencies: {
8031
- "@hasna/machines": "0.0.49"
8032
- },
8033
- devDependencies: {
8034
- "@types/bun": "latest",
8035
- typescript: "^5.7.3"
8036
- },
8037
- publishConfig: {
8038
- registry: "https://registry.npmjs.org",
8039
- access: "public"
8040
- }
8041
- };
8042
-
8043
- // src/lib/version.ts
8044
- function packageVersion() {
8045
- if (typeof package_default.version !== "string" || package_default.version.trim() === "")
8046
- throw new Error("package.json version is missing");
8047
- return package_default.version;
8048
- }
8049
8239
 
8050
8240
  // src/lib/templates.ts
8051
8241
  import { execFileSync } from "child_process";
8052
8242
  import { existsSync as existsSync7 } from "fs";
8053
- import { homedir as homedir3 } from "os";
8243
+ import { homedir as homedir4 } from "os";
8054
8244
  import { basename as basename4, isAbsolute as isAbsolute2, join as join7, relative, resolve as resolve4 } from "path";
8055
8245
 
8056
8246
  // src/lib/template-kit.ts
@@ -8131,6 +8321,7 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8131
8321
  { name: "taskTitle", description: "Human-readable task title." },
8132
8322
  projectPathVariable(),
8133
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." },
8134
8325
  ...routeScopeVariables(),
8135
8326
  ...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
8136
8327
  ]
@@ -8171,6 +8362,8 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8171
8362
  variables: [
8172
8363
  { name: "taskId", required: true, description: "Todos task id." },
8173
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." },
8174
8367
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
8175
8368
  roleAuthProfileVariable("triage"),
8176
8369
  roleAuthProfileVariable("planner"),
@@ -8301,28 +8494,29 @@ function verifierRuntimeGuidance(input) {
8301
8494
  ].join(`
8302
8495
  `);
8303
8496
  }
8304
- function todosInspectLine(todosProjectPath, taskId) {
8305
- return `- Inspect first: todos --project ${todosProjectPath} inspect ${taskId}`;
8497
+ function todosInspectLine(todosProjectPath, taskId, todosDbPath) {
8498
+ return `- Inspect first: ${todosCommand(todosProjectPath, todosDbPath)} inspect ${taskId}`;
8306
8499
  }
8307
- function todosStartLine(todosProjectPath, taskId) {
8308
- 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}`;
8309
8502
  }
8310
- function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
8311
- 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}>"`;
8312
8505
  }
8313
- function todosVerificationLine(todosProjectPath, taskId) {
8314
- 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>"`;
8315
8508
  }
8316
- function todosDoneLine(todosProjectPath, taskId) {
8317
- 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}`;
8318
8511
  }
8319
- function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
8512
+ function todosExactCommandsFragment(todosProjectPath, taskId, commandLines, todosDbPath) {
8320
8513
  return [
8321
8514
  `Todos project path: ${todosProjectPath}`,
8515
+ todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
8322
8516
  "Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
8323
- todosInspectLine(todosProjectPath, taskId),
8517
+ todosInspectLine(todosProjectPath, taskId, todosDbPath),
8324
8518
  ...commandLines
8325
- ];
8519
+ ].filter((line) => Boolean(line));
8326
8520
  }
8327
8521
  function worktreePrompt(plan) {
8328
8522
  if (plan.enabled) {
@@ -8359,10 +8553,19 @@ function worktreeContextFragment(plan) {
8359
8553
  function shellQuote3(value) {
8360
8554
  return `'${value.replace(/'/g, `'\\''`)}'`;
8361
8555
  }
8362
- 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) {
8363
8566
  return [
8364
8567
  "set -euo pipefail",
8365
- `todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
8568
+ `${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
8366
8569
  `printf "source task %s resolved in todos project %s\\n" ${shellQuote3(taskId)} ${shellQuote3(todosProjectPath)}`
8367
8570
  ].join(`
8368
8571
  `);
@@ -8422,10 +8625,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
8422
8625
  "console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
8423
8626
  ].join(`
8424
8627
  `);
8425
- function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
8628
+ function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker, todosDbPath) {
8426
8629
  return [
8427
8630
  "set -euo pipefail",
8428
- `task_json="$(todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)})"`,
8631
+ `task_json="$(${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)})"`,
8429
8632
  `TASK_JSON="$task_json" STAGE=${shellQuote3(stage)} bun - <<'BUN'`,
8430
8633
  LIFECYCLE_GATE_SCRIPT_HEAD,
8431
8634
  `const goMarker = ${JSON.stringify(goMarker)};`,
@@ -8441,6 +8644,7 @@ var PR_HANDOFF_SCRIPT = [
8441
8644
  "const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
8442
8645
  "const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
8443
8646
  "const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
8647
+ "const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
8444
8648
  "const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
8445
8649
  "const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
8446
8650
  "const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
@@ -8458,7 +8662,8 @@ var PR_HANDOFF_SCRIPT = [
8458
8662
  "};",
8459
8663
  "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
8460
8664
  "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
8461
- "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 });",
8462
8667
  "const comment = (text) => {",
8463
8668
  " const result = todos('comment', taskId, text);",
8464
8669
  " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
@@ -8587,6 +8792,7 @@ function prHandoffCommand(opts) {
8587
8792
  `export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote3(opts.artifactPath)}`,
8588
8793
  `export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote3(opts.taskId)}`,
8589
8794
  `export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote3(opts.todosProjectPath)}`,
8795
+ opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote3(opts.todosDbPath)}` : undefined,
8590
8796
  `export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote3(opts.worktreeCwd)}`,
8591
8797
  `export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
8592
8798
  `export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
@@ -8597,7 +8803,7 @@ function prHandoffCommand(opts) {
8597
8803
  "bun - <<'BUN'",
8598
8804
  PR_HANDOFF_SCRIPT,
8599
8805
  "BUN"
8600
- ].join(`
8806
+ ].filter((line) => Boolean(line)).join(`
8601
8807
  `);
8602
8808
  }
8603
8809
  // src/lib/templates-custom.ts
@@ -9031,10 +9237,10 @@ function normalizeWorktreeMode(mode) {
9031
9237
  }
9032
9238
  function defaultWorktreeRoot(root) {
9033
9239
  if (root?.trim()) {
9034
- const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
9240
+ const expanded = root.trim().replace(/^~(?=$|\/)/, homedir4());
9035
9241
  return isAbsolute2(expanded) ? expanded : resolve4(expanded);
9036
9242
  }
9037
- return join7(homedir3(), ".hasna", "loops", "worktrees");
9243
+ return join7(homedir4(), ".hasna", "loops", "worktrees");
9038
9244
  }
9039
9245
  function gitRootFor(path) {
9040
9246
  if (!existsSync7(path))
@@ -9187,12 +9393,12 @@ function commandStep(opts) {
9187
9393
  ...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
9188
9394
  };
9189
9395
  }
9190
- function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
9396
+ function sourceTaskGateStep(todosProjectPath, taskId, plan, description, todosDbPath) {
9191
9397
  return commandStep({
9192
9398
  id: "source-task-gate",
9193
9399
  name: "Source Task Gate",
9194
9400
  description,
9195
- command: sourceTaskGateCommand(todosProjectPath, taskId),
9401
+ command: sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath),
9196
9402
  cwd: plan.originalCwd,
9197
9403
  timeoutMs: 60000,
9198
9404
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9204,7 +9410,7 @@ function lifecycleGateStep(opts) {
9204
9410
  name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
9205
9411
  description: opts.description,
9206
9412
  dependsOn: opts.dependsOn,
9207
- 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),
9208
9414
  cwd: opts.plan.originalCwd,
9209
9415
  timeoutMs: 2 * 60000,
9210
9416
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9213,7 +9419,7 @@ function lifecycleGateStep(opts) {
9213
9419
  function prHandoffArtifactPath(plan, taskId) {
9214
9420
  return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
9215
9421
  }
9216
- function prHandoffStep(input, plan, todosProjectPath) {
9422
+ function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
9217
9423
  return commandStep({
9218
9424
  id: "pr-handoff",
9219
9425
  name: "PR Handoff",
@@ -9223,6 +9429,7 @@ function prHandoffStep(input, plan, todosProjectPath) {
9223
9429
  artifactPath: prHandoffArtifactPath(plan, input.taskId),
9224
9430
  taskId: input.taskId,
9225
9431
  todosProjectPath,
9432
+ todosDbPath,
9226
9433
  worktreeCwd: plan.cwd,
9227
9434
  worktreeRoot: plan.path ?? plan.cwd,
9228
9435
  expectedBranch: plan.branch ?? ""
@@ -9309,6 +9516,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9309
9516
  if (!input.projectPath?.trim())
9310
9517
  throw new Error("projectPath is required");
9311
9518
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
9519
+ const todosDbPath = input.todosDbPath;
9312
9520
  const plan = worktreePlan(input, input.taskId);
9313
9521
  const taskContext = {
9314
9522
  taskId: input.taskId,
@@ -9319,15 +9527,17 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9319
9527
  projectPath: input.projectPath,
9320
9528
  routeProjectPath: input.routeProjectPath,
9321
9529
  projectGroup: input.projectGroup,
9530
+ todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
9531
+ todosDbPath,
9322
9532
  worktree: worktreeContextFragment(plan)
9323
9533
  };
9324
9534
  const workerPrompt = [
9325
9535
  ...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
9326
9536
  worktreePrompt(plan),
9327
9537
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9328
- todosStartLine(todosProjectPath, input.taskId),
9329
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
9330
- ]),
9538
+ todosStartLine(todosProjectPath, input.taskId, todosDbPath),
9539
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers", todosDbPath)
9540
+ ], todosDbPath),
9331
9541
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
9332
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.",
9333
9543
  NO_TMUX_DISPATCH_FRAGMENT,
@@ -9340,9 +9550,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9340
9550
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
9341
9551
  worktreePrompt(plan),
9342
9552
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9343
- todosVerificationLine(todosProjectPath, input.taskId),
9344
- todosDoneLine(todosProjectPath, input.taskId)
9345
- ]),
9553
+ todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
9554
+ todosDoneLine(todosProjectPath, input.taskId, todosDbPath)
9555
+ ], todosDbPath),
9346
9556
  adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
9347
9557
  verifierRuntimeGuidance(input),
9348
9558
  TASK_VERIFIER_DECISION_FRAGMENT,
@@ -9357,7 +9567,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9357
9567
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
9358
9568
  version: 1,
9359
9569
  steps: [
9360
- 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),
9361
9571
  ...workerVerifierSteps({
9362
9572
  input,
9363
9573
  seed: input.taskId,
@@ -9377,6 +9587,7 @@ function renderTaskLifecycleWorkflow(input) {
9377
9587
  if (!input.projectPath?.trim())
9378
9588
  throw new Error("projectPath is required");
9379
9589
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
9590
+ const todosDbPath = input.todosDbPath;
9380
9591
  const plan = worktreePlan(input, input.taskId);
9381
9592
  const taskContext = {
9382
9593
  taskId: input.taskId,
@@ -9388,6 +9599,7 @@ function renderTaskLifecycleWorkflow(input) {
9388
9599
  routeProjectPath: input.routeProjectPath,
9389
9600
  projectGroup: input.projectGroup,
9390
9601
  todosProjectPath,
9602
+ todosDbPath,
9391
9603
  worktree: worktreeContextFragment(plan)
9392
9604
  };
9393
9605
  const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
@@ -9401,8 +9613,8 @@ function renderTaskLifecycleWorkflow(input) {
9401
9613
  const shared = [
9402
9614
  worktreePrompt(plan),
9403
9615
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9404
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
9405
- ]),
9616
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker", todosDbPath)
9617
+ ], todosDbPath),
9406
9618
  NO_TMUX_DISPATCH_FRAGMENT,
9407
9619
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
9408
9620
  "",
@@ -9411,7 +9623,7 @@ function renderTaskLifecycleWorkflow(input) {
9411
9623
  ].join(`
9412
9624
  `);
9413
9625
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
9414
- const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
9626
+ const blockTaskCommand = `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
9415
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.`;
9416
9628
  const triagePrompt = [
9417
9629
  ...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
@@ -9439,7 +9651,7 @@ function renderTaskLifecycleWorkflow(input) {
9439
9651
  const workerPrompt = [
9440
9652
  ...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
9441
9653
  shared,
9442
- todosStartLine(todosProjectPath, input.taskId),
9654
+ todosStartLine(todosProjectPath, input.taskId, todosDbPath),
9443
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.",
9444
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,
9445
9657
  WORKER_LEAVES_COMPLETION_FRAGMENT
@@ -9448,8 +9660,8 @@ function renderTaskLifecycleWorkflow(input) {
9448
9660
  const verifierPrompt = [
9449
9661
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
9450
9662
  shared,
9451
- todosVerificationLine(todosProjectPath, input.taskId),
9452
- todosDoneLine(todosProjectPath, input.taskId),
9663
+ todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
9664
+ todosDoneLine(todosProjectPath, input.taskId, todosDbPath),
9453
9665
  adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
9454
9666
  verifierRuntimeGuidance(input),
9455
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,
@@ -9458,7 +9670,7 @@ function renderTaskLifecycleWorkflow(input) {
9458
9670
  ].filter(Boolean).join(`
9459
9671
  `);
9460
9672
  const steps = [
9461
- 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),
9462
9674
  {
9463
9675
  id: "triage",
9464
9676
  name: "Triage",
@@ -9472,6 +9684,7 @@ function renderTaskLifecycleWorkflow(input) {
9472
9684
  description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
9473
9685
  dependsOn: ["triage"],
9474
9686
  todosProjectPath,
9687
+ todosDbPath,
9475
9688
  taskId: input.taskId,
9476
9689
  goMarker: gateMarker("triage", "go"),
9477
9690
  blockedMarker: gateMarker("triage", "blocked"),
@@ -9490,6 +9703,7 @@ function renderTaskLifecycleWorkflow(input) {
9490
9703
  description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
9491
9704
  dependsOn: ["planner"],
9492
9705
  todosProjectPath,
9706
+ todosDbPath,
9493
9707
  taskId: input.taskId,
9494
9708
  goMarker: gateMarker("planner", "go"),
9495
9709
  blockedMarker: gateMarker("planner", "blocked"),
@@ -9505,7 +9719,7 @@ function renderTaskLifecycleWorkflow(input) {
9505
9719
  }
9506
9720
  ];
9507
9721
  if (input.prHandoff) {
9508
- steps.push(prHandoffStep(input, plan, todosProjectPath));
9722
+ steps.push(prHandoffStep(input, plan, todosProjectPath, todosDbPath));
9509
9723
  }
9510
9724
  steps.push({
9511
9725
  id: "verifier",
@@ -9730,6 +9944,7 @@ function renderLifecycleBoundedTemplate(id, values) {
9730
9944
  taskTitle: values.taskTitle,
9731
9945
  taskDescription: values.taskDescription,
9732
9946
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
9947
+ todosDbPath: values.todosDbPath,
9733
9948
  triageAuthProfile: values.triageAuthProfile,
9734
9949
  plannerAuthProfile: values.plannerAuthProfile,
9735
9950
  triageAccount: accountVar(values.triageAccount, values.accountTool),
@@ -9794,6 +10009,7 @@ function renderBuiltinLoopTemplate(id, values) {
9794
10009
  taskTitle: values.taskTitle,
9795
10010
  taskDescription: values.taskDescription,
9796
10011
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
10012
+ todosDbPath: values.todosDbPath,
9797
10013
  eventId: values.eventId,
9798
10014
  eventType: values.eventType
9799
10015
  });
@@ -10241,7 +10457,8 @@ function writeRouteEvidence(kind, value, evidenceDir) {
10241
10457
  mkdirSync9(evidenceDir, { recursive: true, mode: 448 });
10242
10458
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
10243
10459
  const evidencePath = join9(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
10244
- 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" });
10245
10462
  return evidencePath;
10246
10463
  }
10247
10464
  function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
@@ -10837,6 +11054,70 @@ function todosTaskRouteTemplateId(opts) {
10837
11054
  }
10838
11055
  return id;
10839
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
+ }
10840
11121
  async function readEventEnvelopeInput(opts = {}) {
10841
11122
  const raw = opts.eventJson ?? (opts.eventFile ? readFileSync8(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
10842
11123
  const event = JSON.parse(raw);
@@ -11013,11 +11294,13 @@ function routeTodosTaskEvent(event, opts) {
11013
11294
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
11014
11295
  if (!taskId)
11015
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);
11016
11299
  const eligibility = taskRouteEligibility(data, metadata);
11017
11300
  if (!eligibility.eligible) {
11018
11301
  return {
11019
11302
  kind: "skipped",
11020
- value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
11303
+ value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility, sourceTask: sourceTaskPublic },
11021
11304
  human: `skipped task ${taskId}: ${eligibility.reason}`
11022
11305
  };
11023
11306
  }
@@ -11032,11 +11315,11 @@ function routeTodosTaskEvent(event, opts) {
11032
11315
  "project_canonical_path",
11033
11316
  "cwd"
11034
11317
  ]);
11035
- const projectPath = dataProjectPath ?? metadataProjectPath ?? opts.projectPath ?? process.cwd();
11318
+ const projectPath = dataProjectPath ?? metadataProjectPath ?? sourceTask.sourceRepoPath ?? opts.projectPath ?? process.cwd();
11036
11319
  const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
11037
11320
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
11038
11321
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
11039
- const idempotencyKey = `todos-task:${taskId}`;
11322
+ const idempotencyKey = sourceTask.idempotencyKey;
11040
11323
  const idempotencySuffix = stableSuffix(idempotencyKey);
11041
11324
  const namePrefix = opts.namePrefix ?? "event:todos-task";
11042
11325
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -11049,7 +11332,7 @@ function routeTodosTaskEvent(event, opts) {
11049
11332
  const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11050
11333
  const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11051
11334
  const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
11052
- return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
11335
+ return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
11053
11336
  }
11054
11337
  } finally {
11055
11338
  store.close();
@@ -11112,12 +11395,14 @@ function routeTodosTaskEvent(event, opts) {
11112
11395
  prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
11113
11396
  eventId: event.id,
11114
11397
  eventType: event.type,
11115
- todosProjectPath: opts.todosProject
11398
+ todosProjectPath: sourceTask.sourceRepoPath ?? opts.todosProject,
11399
+ todosDbPath: sourceTask.sourceDbPath
11116
11400
  };
11117
11401
  const workflowContext = {
11118
11402
  name: workflowName,
11119
11403
  type: "todos-task-event-workflow",
11120
- event: event.id
11404
+ event: event.id,
11405
+ sourceTask: sourceTaskPublic
11121
11406
  };
11122
11407
  let workflowBody = templateId === TASK_LIFECYCLE_TEMPLATE_ID ? renderTaskLifecycleWorkflow(workflowInput) : renderTodosTaskWorkerVerifierWorkflow(workflowInput);
11123
11408
  workflowBody.name = workflowName;
@@ -11130,13 +11415,13 @@ function routeTodosTaskEvent(event, opts) {
11130
11415
  kind: "event",
11131
11416
  id: event.id,
11132
11417
  dedupeKey: idempotencyKey,
11133
- raw: { type: event.type, source: event.source, subject: event.subject }
11418
+ raw: { type: event.type, source: event.source, subject: event.subject, sourceTask: sourceTaskPublic }
11134
11419
  },
11135
11420
  subjectRef: {
11136
11421
  kind: "task",
11137
11422
  id: taskId,
11138
11423
  path: routeProjectPath,
11139
- raw: { title: taskTitle, description: taskDescription }
11424
+ raw: { title: taskTitle, description: taskDescription, sourceTask: sourceTaskPublic }
11140
11425
  },
11141
11426
  intent: "route",
11142
11427
  scope: {
@@ -11149,7 +11434,8 @@ function routeTodosTaskEvent(event, opts) {
11149
11434
  accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
11150
11435
  providerRouting: providerRoutingPublic(providerRouting),
11151
11436
  prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
11152
- concurrencyGroup: projectGroup ?? routeProjectPath
11437
+ concurrencyGroup: projectGroup ?? routeProjectPath,
11438
+ sourceTask: sourceTaskPublic
11153
11439
  },
11154
11440
  outputPolicy: {
11155
11441
  report: "always",
@@ -11174,9 +11460,10 @@ function routeTodosTaskEvent(event, opts) {
11174
11460
  humanSubject: `task ${taskId}`,
11175
11461
  valueExtras: {
11176
11462
  providerRouting: providerRoutingPublic(providerRouting),
11177
- prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined
11463
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
11464
+ sourceTask: sourceTaskPublic
11178
11465
  },
11179
- dedupeValueExtras: {}
11466
+ dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {}
11180
11467
  });
11181
11468
  }
11182
11469
  function routeGenericEvent(event, opts) {
@@ -11303,9 +11590,9 @@ import { resolve as resolve8 } from "path";
11303
11590
  import { closeSync, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync9, rmSync as rmSync6 } from "fs";
11304
11591
  import { spawnSync as spawnSync8 } from "child_process";
11305
11592
  import { join as join10 } from "path";
11306
- import { tmpdir as tmpdir2 } from "os";
11593
+ import { homedir as homedir5, tmpdir as tmpdir2 } from "os";
11307
11594
  function defaultLoopsProject() {
11308
- return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME ?? "/home/hasna"}/.hasna/loops`;
11595
+ return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME || homedir5()}/.hasna/loops`;
11309
11596
  }
11310
11597
  function runLocalCommand(command, args, opts = {}) {
11311
11598
  const result = spawnSync8(command, args, {
@@ -11313,7 +11600,7 @@ function runLocalCommand(command, args, opts = {}) {
11313
11600
  encoding: "utf8",
11314
11601
  timeout: opts.timeoutMs ?? 30000,
11315
11602
  maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
11316
- env: process.env
11603
+ env: { ...process.env, ...opts.env }
11317
11604
  });
11318
11605
  return {
11319
11606
  ok: result.status === 0,
@@ -11380,8 +11667,66 @@ function taskField(task, keys) {
11380
11667
  }
11381
11668
  return;
11382
11669
  }
11383
- function taskListId(task) {
11384
- 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)];
11385
11730
  }
11386
11731
  function taskProjectId(task) {
11387
11732
  return taskField(task, ["project_id", "projectId"]);
@@ -11395,12 +11740,14 @@ function taskProjectPath(task) {
11395
11740
  const metadata = objectField2(task.metadata) ?? {};
11396
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"]);
11397
11742
  }
11398
- function taskDrainEvent(task) {
11743
+ function taskDrainEvent(task, opts = {}) {
11399
11744
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
11400
11745
  if (!taskId)
11401
11746
  throw new Error("todos ready returned a task without an id");
11402
11747
  const metadata = objectField2(task.metadata) ?? {};
11403
11748
  const workingDir = taskProjectPath(task);
11749
+ const sourceIdentity = taskSourceIdentity(task, taskId);
11750
+ const sourceTask = publicSourceTaskIdentity(sourceIdentity);
11404
11751
  const data = {
11405
11752
  ...task,
11406
11753
  id: taskId,
@@ -11410,14 +11757,21 @@ function taskDrainEvent(task) {
11410
11757
  tags: tagsFromValue2(task.tags),
11411
11758
  metadata
11412
11759
  };
11760
+ if (opts.sourceRouteEligible) {
11761
+ data.route_enabled = true;
11762
+ data.route_enabled_by = "source_route_state";
11763
+ }
11413
11764
  if (workingDir) {
11414
11765
  data.working_dir = workingDir;
11415
11766
  data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
11416
11767
  data.cwd = taskField(task, ["cwd"]) ?? workingDir;
11417
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}`;
11418
11772
  const time = new Date().toISOString();
11419
11773
  return {
11420
- id: `drain-todos-task-${taskId}`,
11774
+ id: eventId,
11421
11775
  type: "task.created",
11422
11776
  source: "@hasna/todos",
11423
11777
  subject: taskId,
@@ -11428,6 +11782,8 @@ function taskDrainEvent(task) {
11428
11782
  metadata: {
11429
11783
  ...metadata,
11430
11784
  ...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
11785
+ ...sourceTask,
11786
+ ...sourceTask ? { source_task: sourceTask } : {},
11431
11787
  drained_by: "@hasna/loops",
11432
11788
  drained_from: "todos ready"
11433
11789
  }
@@ -11436,6 +11792,7 @@ function taskDrainEvent(task) {
11436
11792
  function compactDrainResult(result) {
11437
11793
  const value = result.value;
11438
11794
  const event = objectField2(value.event);
11795
+ const sourceTask = objectField2(value.sourceTask) ?? objectField2(objectField2(event?.metadata)?.source_task) ?? objectField2(objectField2(event?.data)?.source_task);
11439
11796
  const loop = objectField2(value.loop);
11440
11797
  const workflow = objectField2(value.workflow);
11441
11798
  const throttle = objectField2(value.throttle);
@@ -11452,26 +11809,75 @@ function compactDrainResult(result) {
11452
11809
  workflowId: stringField2(workflow?.id),
11453
11810
  workflowName: stringField2(workflow?.name),
11454
11811
  providerRouting,
11812
+ sourceTask,
11455
11813
  requeue,
11456
11814
  queuedAtSource: value.queuedAtSource
11457
11815
  };
11458
11816
  }
11459
- function loadReadyTodosTasks(opts, scanLimit) {
11460
- const todosProject = opts.todosProject ?? defaultLoopsProject();
11461
- const args = ["--project", todosProject, "--json", "ready", "--limit", String(scanLimit)];
11462
- const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
11463
- if (!result.ok)
11464
- throw new Error(result.stderr || result.error || "todos ready failed");
11465
- let parsed;
11817
+ function numberField2(value) {
11818
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
11819
+ }
11820
+ function parseTodosReadyJson(stdout) {
11466
11821
  try {
11467
- parsed = JSON.parse(result.stdout || "[]");
11822
+ return JSON.parse(stdout || "[]");
11468
11823
  } catch (error) {
11469
11824
  const message = error instanceof Error ? error.message : String(error);
11470
- throw new Error(`failed to parse todos ready --json output (${result.stdout.length} bytes): ${message}`);
11471
- }
11472
- if (!Array.isArray(parsed))
11473
- throw new Error("todos ready --json returned a non-array value");
11474
- 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
+ };
11475
11881
  }
11476
11882
  function resolveTaskListFilter(todosProject, filter) {
11477
11883
  const wanted = filter?.trim();
@@ -11487,7 +11893,7 @@ function resolveTaskListFilter(todosProject, filter) {
11487
11893
  function taskMatchesDrainFilters(task, filters) {
11488
11894
  if (filters.projectId && taskProjectId(task) !== filters.projectId)
11489
11895
  return false;
11490
- if (filters.taskListId && taskListId(task) !== filters.taskListId)
11896
+ if (filters.taskList && !taskListValues(task).includes(filters.taskList))
11491
11897
  return false;
11492
11898
  if (filters.projectPathPrefix) {
11493
11899
  const path = taskProjectPath(task);
@@ -11525,19 +11931,77 @@ function skippedDrainTask(task, event, reason, extra = {}) {
11525
11931
  function isSkippableDrainRouteError(message) {
11526
11932
  return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
11527
11933
  }
11528
- 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 = {}) {
11529
11978
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
11530
11979
  if (!taskId)
11531
11980
  return { attempted: false, reason: "task id missing" };
11532
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.`;
11533
- const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
11534
- const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
11535
- 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 });
11536
11993
  const ok = commentResult.ok && tagResult.ok && untagResult.ok;
11537
11994
  return {
11538
11995
  ok,
11539
11996
  attempted: true,
11540
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
+ },
11541
12005
  error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
11542
12006
  comment: todosMutationSummary(commentResult),
11543
12007
  tagNoAuto: todosMutationSummary(tagResult),
@@ -11546,17 +12010,19 @@ function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
11546
12010
  }
11547
12011
  function drainTodosTaskRoutes(opts) {
11548
12012
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
11549
- const todosProject = opts.todosProject ?? defaultLoopsProject();
12013
+ const sourceMode = hasTodosSourceOptions(opts);
12014
+ const todosProject = sourceMode ? opts.todosProject : opts.todosProject ?? defaultLoopsProject();
11550
12015
  const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
11551
- const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
12016
+ const taskListFilter = sourceMode ? opts.taskList?.trim() : resolveTaskListFilter(todosProject ?? defaultLoopsProject(), opts.taskList);
11552
12017
  const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
11553
12018
  const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
11554
12019
  const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
11555
12020
  const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
11556
- const ready = loadReadyTodosTasks(opts, scanLimit);
12021
+ const loaded = loadReadyTodosTasks(opts, scanLimit, sourceMode);
12022
+ const ready = loaded.tasks;
11557
12023
  const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
11558
12024
  projectId: opts.todosProjectId,
11559
- taskListId: taskListFilter,
12025
+ taskList: taskListFilter,
11560
12026
  projectPathPrefix: opts.projectPathPrefix,
11561
12027
  tags: requiredTags
11562
12028
  }));
@@ -11569,13 +12035,25 @@ function drainTodosTaskRoutes(opts) {
11569
12035
  let event;
11570
12036
  let result;
11571
12037
  try {
11572
- 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 });
11573
12051
  result = routeTodosTaskEvent(event, opts);
11574
12052
  } catch (error) {
11575
12053
  const message = error instanceof Error ? error.message : String(error);
11576
12054
  if (!isSkippableDrainRouteError(message))
11577
12055
  throw error;
11578
- 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 });
11579
12057
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
11580
12058
  }
11581
12059
  results.push(result);
@@ -11585,6 +12063,12 @@ function drainTodosTaskRoutes(opts) {
11585
12063
  const report = {
11586
12064
  drainedAt: new Date().toISOString(),
11587
12065
  todosProject,
12066
+ sourceMode,
12067
+ sourceRoots: loaded.sourceRoots,
12068
+ sourceStores: loaded.sourceStores,
12069
+ sourceIncludes: loaded.sourceIncludes,
12070
+ sourceExcludes: loaded.sourceExcludes,
12071
+ sourceDiscovery: loaded.sourceDiscovery,
11588
12072
  templateId: todosTaskRouteTemplateId(opts),
11589
12073
  todosProjectId: opts.todosProjectId,
11590
12074
  taskList: opts.taskList,
@@ -11597,14 +12081,14 @@ function drainTodosTaskRoutes(opts) {
11597
12081
  scanned: ready.length,
11598
12082
  candidates: candidates.length,
11599
12083
  filteredCandidates: filteredCandidates.length,
11600
- 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,
11601
12085
  considered: results.length,
11602
12086
  created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
11603
12087
  deduped: results.filter((result) => result.kind === "deduped").length,
11604
12088
  throttled: results.filter((result) => result.kind === "throttled").length,
11605
12089
  skipped: results.filter((result) => result.kind === "skipped").length,
11606
12090
  maxDispatch,
11607
- source: "todos ready",
12091
+ source: sourceMode ? "todos ready source discovery" : "todos ready",
11608
12092
  dryRun: Boolean(opts.dryRun),
11609
12093
  results: results.map((result) => ({ kind: result.kind, ...result.value }))
11610
12094
  };
@@ -11612,6 +12096,12 @@ function drainTodosTaskRoutes(opts) {
11612
12096
  const value = opts.compact ? {
11613
12097
  drainedAt: report.drainedAt,
11614
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,
11615
12105
  templateId: report.templateId,
11616
12106
  todosProjectId: report.todosProjectId,
11617
12107
  taskList: report.taskList,
@@ -11637,7 +12127,7 @@ function drainTodosTaskRoutes(opts) {
11637
12127
  results: results.map(compactDrainResult)
11638
12128
  } : { ...report, evidencePath };
11639
12129
  return {
11640
- value,
12130
+ value: scrubSecretsDeep(value),
11641
12131
  human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`
11642
12132
  };
11643
12133
  }
@@ -11915,6 +12405,34 @@ var EVENT_INPUT_OPTION_SPECS = [
11915
12405
  { flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
11916
12406
  ];
11917
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
+ },
11918
12436
  { flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
11919
12437
  { flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
11920
12438
  { flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },
@@ -12156,6 +12674,20 @@ function runAction(fn) {
12156
12674
  }
12157
12675
  };
12158
12676
  }
12677
+ function printDeploymentStatus(status, opts = {}) {
12678
+ if (isJson() || opts.json)
12679
+ console.log(JSON.stringify(status, null, 2));
12680
+ else {
12681
+ console.log(deploymentStatusLine(status));
12682
+ for (const warning of status.warnings)
12683
+ console.log(`warn ${warning}`);
12684
+ }
12685
+ }
12686
+ function deploymentStatusCommand(mode) {
12687
+ return (opts = {}) => {
12688
+ printDeploymentStatus(buildDeploymentStatus({ perspective: mode }), opts);
12689
+ };
12690
+ }
12159
12691
  function printCreatedLoop(loop, human, preflight) {
12160
12692
  if (preflight !== undefined)
12161
12693
  print({ loop: publicLoop(loop), preflight }, human);
@@ -12484,6 +13016,11 @@ var routes = program.command("routes").alias("route").description("create, inspe
12484
13016
  var events = program.command("events").description("(deprecated) Hasna event envelope aliases for 'routes create' and 'routes drain'");
12485
13017
  var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
12486
13018
  var goal = program.command("goal").description("inspect goal runs");
13019
+ program.command("mode").description("show the active OpenLoops deployment mode").option("--json", "print JSON").action(runAction(deploymentStatusCommand()));
13020
+ var selfHosted = program.command("self-hosted").alias("selfhosted").description("inspect the self-hosted OpenLoops contract");
13021
+ selfHosted.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("self_hosted")));
13022
+ var cloud = program.command("cloud").description("inspect the hosted OpenLoops contract");
13023
+ cloud.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("cloud")));
12487
13024
  function formatTemplateVariable(template, name) {
12488
13025
  const variable = template.variables.find((entry) => entry.name === name);
12489
13026
  const placeholder = variable?.default ? variable.default : `<${name}>`;