@hasna/loops 0.3.20 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -324,7 +324,10 @@ renames only with `--apply`. Apply mode writes a SQLite backup under
324
324
  groups loops with the same normalized name, cwd, and schedule. `hygiene scripts`
325
325
  inventories loops whose command still references `~/.hasna/loops/scripts`.
326
326
  `hygiene route-tasks` upserts deduped Todos tasks for hygiene findings with
327
- stable fingerprints and `no_tmux_dispatch=true` metadata.
327
+ stable fingerprints and `no_tmux_dispatch=true` metadata. Route commands use a
328
+ package-managed cursor under `<LOOPS_DATA_DIR>/route-cursors.json` so bounded
329
+ `--max-actions` runs advance through all findings over repeated scheduled runs
330
+ instead of reprocessing only the first batch.
328
331
 
329
332
  Archive loops when retiring old automation but preserving history:
330
333
 
package/dist/cli/index.js CHANGED
@@ -2833,6 +2833,7 @@ function commandSpec(target) {
2833
2833
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2834
2834
  account: agentTarget.account,
2835
2835
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2836
+ nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
2836
2837
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2837
2838
  stdin: agentTarget.prompt,
2838
2839
  allowlist: agentTarget.allowlist
@@ -2914,6 +2915,9 @@ function remotePreflightScript(spec, metadata) {
2914
2915
  if (spec.preflightAnyOf?.length) {
2915
2916
  lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
2916
2917
  }
2918
+ if (spec.nativeAuthProfile?.provider === "codewith") {
2919
+ lines.push(`__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
2920
+ }
2917
2921
  return lines.join(`
2918
2922
  `);
2919
2923
  }
@@ -2929,6 +2933,29 @@ function transportEnv(opts) {
2929
2933
  env.PATH = normalizeExecutionPath(env);
2930
2934
  return env;
2931
2935
  }
2936
+ function preflightNativeAuthProfile(spec, env) {
2937
+ if (!spec.nativeAuthProfile)
2938
+ return;
2939
+ if (spec.nativeAuthProfile.provider !== "codewith")
2940
+ return;
2941
+ const result = spawnSync2(spec.command, ["profile", "list"], {
2942
+ encoding: "utf8",
2943
+ env,
2944
+ stdio: ["ignore", "pipe", "pipe"],
2945
+ timeout: 15000
2946
+ });
2947
+ if (result.error) {
2948
+ throw new Error(`codewith auth profile preflight failed: ${result.error.message}`);
2949
+ }
2950
+ if ((result.status ?? 1) !== 0) {
2951
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2952
+ throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
2953
+ }
2954
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
2955
+ if (!profiles.has(spec.nativeAuthProfile.profile)) {
2956
+ throw new Error(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`);
2957
+ }
2958
+ }
2932
2959
  function preflightRemoteSpec(spec, machine, metadata, opts) {
2933
2960
  const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2934
2961
  const result = spawnSync2(plan.command, plan.args, {
@@ -3070,6 +3097,7 @@ function preflightTarget(target, metadata = {}, opts = {}) {
3070
3097
  if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
3071
3098
  throw new Error(`none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
3072
3099
  }
3100
+ preflightNativeAuthProfile(spec, env);
3073
3101
  return {
3074
3102
  command: spec.command,
3075
3103
  accountProfile: spec.account?.profile,
@@ -3111,6 +3139,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3111
3139
  durationMs: 0
3112
3140
  };
3113
3141
  }
3142
+ try {
3143
+ preflightNativeAuthProfile(spec, env);
3144
+ } catch (err) {
3145
+ return {
3146
+ status: "failed",
3147
+ stdout: "",
3148
+ stderr: "",
3149
+ error: err instanceof Error ? err.message : String(err),
3150
+ startedAt,
3151
+ finishedAt: nowIso(),
3152
+ durationMs: 0
3153
+ };
3154
+ }
3114
3155
  const child = spawn(spec.command, spec.args, {
3115
3156
  cwd: spec.cwd,
3116
3157
  env,
@@ -3767,11 +3808,21 @@ async function executeWorkflow(store, workflow, opts = {}) {
3767
3808
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
3768
3809
  }
3769
3810
  function preflightWorkflow(workflow, opts = {}) {
3770
- return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
3771
- workflowId: workflow.id,
3772
- workflowName: workflow.name,
3773
- workflowStepId: step.id
3774
- }, opts));
3811
+ return workflowExecutionOrder(workflow).map((step) => {
3812
+ try {
3813
+ return {
3814
+ workflowStepId: step.id,
3815
+ ...preflightTarget(targetWithStepAccount(step), {
3816
+ workflowId: workflow.id,
3817
+ workflowName: workflow.name,
3818
+ workflowStepId: step.id
3819
+ }, opts)
3820
+ };
3821
+ } catch (error) {
3822
+ const message = error instanceof Error ? error.message : String(error);
3823
+ throw new Error(`workflow step ${step.id} preflight failed: ${message}`);
3824
+ }
3825
+ });
3775
3826
  }
3776
3827
  async function executeLoopTarget(store, loop, run, opts = {}) {
3777
3828
  if (loop.target.type !== "workflow") {
@@ -4757,6 +4808,7 @@ function recommendedTask(loop, run, failure, route) {
4757
4808
  `Status: ${run.status}`,
4758
4809
  `Classification: ${failure.classification}`,
4759
4810
  `Fingerprint: ${failure.fingerprint}`,
4811
+ `No-tmux routing: Do not dispatch or paste prompts into tmux panes; use task-triggered headless worker/verifier workflows only.`,
4760
4812
  route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4761
4813
  route.provider ? `Provider: ${route.provider}` : undefined,
4762
4814
  failure.evidence.error ? `Error:
@@ -5091,7 +5143,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5091
5143
  // package.json
5092
5144
  var package_default = {
5093
5145
  name: "@hasna/loops",
5094
- version: "0.3.20",
5146
+ version: "0.3.22",
5095
5147
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5096
5148
  type: "module",
5097
5149
  main: "dist/index.js",
@@ -5332,6 +5384,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5332
5384
  "You are the worker agent for a task-triggered OpenLoops workflow.",
5333
5385
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
5334
5386
  "Claim/start the task if appropriate, inspect the repository/project state, implement only the task scope, run focused validation, preserve unrelated user changes, and update the task with comments, evidence, changed files, commits, and blockers.",
5387
+ "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
5335
5388
  "Do not mark the task complete unless the work is genuinely done and validated.",
5336
5389
  "",
5337
5390
  `Task context JSON: ${compactJson(taskContext)}`
@@ -5343,6 +5396,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5343
5396
  "You are the verifier agent for a task-triggered OpenLoops workflow.",
5344
5397
  "Use fresh context. Inspect the task, repository state, commits, tests, and worker evidence. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
5345
5398
  "If the work is valid, record verification evidence in todos and mark/leave the task in the correct completed state according to the todos CLI. If it is not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
5399
+ "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
5346
5400
  "Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks.",
5347
5401
  "",
5348
5402
  `Task context JSON: ${compactJson(taskContext)}`
@@ -5573,6 +5627,41 @@ function print(value, human) {
5573
5627
  else
5574
5628
  console.log(human);
5575
5629
  }
5630
+ function printCreatedLoop(loop, human, preflight) {
5631
+ if (preflight !== undefined)
5632
+ print({ loop: publicLoop(loop), preflight }, human);
5633
+ else
5634
+ print(publicLoop(loop), human);
5635
+ }
5636
+ function preflightFailed(error, context) {
5637
+ if (!isJson())
5638
+ throw error;
5639
+ const message = error instanceof Error ? error.message : String(error);
5640
+ print({
5641
+ ok: false,
5642
+ created: false,
5643
+ preflight: {
5644
+ ok: false,
5645
+ error: redact(message, 320)
5646
+ },
5647
+ ...context
5648
+ });
5649
+ process.exit(1);
5650
+ }
5651
+ function preflightLoopTarget(target, context, metadata, opts) {
5652
+ try {
5653
+ return preflightTarget(target, metadata, opts);
5654
+ } catch (error) {
5655
+ preflightFailed(error, context);
5656
+ }
5657
+ }
5658
+ function preflightStoredWorkflow(workflow, context, opts) {
5659
+ try {
5660
+ return preflightWorkflow(workflow, opts);
5661
+ } catch (error) {
5662
+ preflightFailed(error, context);
5663
+ }
5664
+ }
5576
5665
  function printTextOutput(value) {
5577
5666
  for (const line of textOutputBlocks(value, { indent: " " }))
5578
5667
  console.log(line);
@@ -5761,6 +5850,53 @@ function stableHash(parts) {
5761
5850
  return createHash2("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
5762
5851
  `)).digest("hex").slice(0, 16);
5763
5852
  }
5853
+ function routeCursorsPath() {
5854
+ return join3(dataDir(), "route-cursors.json");
5855
+ }
5856
+ function readRouteCursors() {
5857
+ const path = routeCursorsPath();
5858
+ if (!existsSync3(path))
5859
+ return {};
5860
+ try {
5861
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
5862
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
5863
+ } catch {
5864
+ return {};
5865
+ }
5866
+ }
5867
+ function writeRouteCursor(key, lastFingerprint) {
5868
+ if (!lastFingerprint)
5869
+ return;
5870
+ const cursors = readRouteCursors();
5871
+ cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
5872
+ writeFileSync3(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
5873
+ }
5874
+ function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
5875
+ const total = items.length;
5876
+ const boundedMax = Math.max(0, Math.floor(Number.isFinite(maxActions) ? maxActions : 0));
5877
+ if (total === 0 || boundedMax === 0) {
5878
+ return { selected: [], cursor: { key: cursorKey, total, maxActions: boundedMax, startIndex: 0 } };
5879
+ }
5880
+ const cursors = readRouteCursors();
5881
+ const previousFingerprint = cursors[cursorKey]?.lastFingerprint;
5882
+ const previousIndex = previousFingerprint ? items.findIndex((item) => fingerprintOf(item) === previousFingerprint) : -1;
5883
+ const startIndex = previousIndex >= 0 ? (previousIndex + 1) % total : 0;
5884
+ const selected = [];
5885
+ const count = Math.min(boundedMax, total);
5886
+ for (let index = 0;index < count; index += 1)
5887
+ selected.push(items[(startIndex + index) % total]);
5888
+ return {
5889
+ selected,
5890
+ cursor: {
5891
+ key: cursorKey,
5892
+ total,
5893
+ maxActions: boundedMax,
5894
+ previousFingerprint,
5895
+ startIndex,
5896
+ lastFingerprint: selected.length ? fingerprintOf(selected[selected.length - 1]) : undefined
5897
+ }
5898
+ };
5899
+ }
5764
5900
  function eventData(event) {
5765
5901
  const data = event.data;
5766
5902
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -5842,6 +5978,36 @@ function booleanLike(value) {
5842
5978
  function hasTruthyField(records, keys) {
5843
5979
  return records.some((record) => keys.some((key) => booleanLike(record[key])));
5844
5980
  }
5981
+ function automationRecords(data, metadata) {
5982
+ const records = [];
5983
+ const dataAutomation = nestedObject(data, "automation");
5984
+ if (dataAutomation)
5985
+ records.push(dataAutomation);
5986
+ const dataTask = nestedObject(data, "task");
5987
+ const dataTaskAutomation = dataTask ? nestedObject(dataTask, "automation") : undefined;
5988
+ if (dataTaskAutomation)
5989
+ records.push(dataTaskAutomation);
5990
+ const dataPayload = nestedObject(data, "payload");
5991
+ const payloadAutomation = dataPayload ? nestedObject(dataPayload, "automation") : undefined;
5992
+ if (payloadAutomation)
5993
+ records.push(payloadAutomation);
5994
+ const payloadTask = dataPayload ? nestedObject(dataPayload, "task") : undefined;
5995
+ const payloadTaskAutomation = payloadTask ? nestedObject(payloadTask, "automation") : undefined;
5996
+ if (payloadTaskAutomation)
5997
+ records.push(payloadTaskAutomation);
5998
+ const dataMetadata = nestedObject(data, "metadata");
5999
+ const dataMetadataAutomation = dataMetadata ? nestedObject(dataMetadata, "automation") : undefined;
6000
+ if (dataMetadataAutomation)
6001
+ records.push(dataMetadataAutomation);
6002
+ const metadataAutomation = nestedObject(metadata, "automation");
6003
+ if (metadataAutomation)
6004
+ records.push(metadataAutomation);
6005
+ const metadataTask = nestedObject(metadata, "task");
6006
+ const metadataTaskAutomation = metadataTask ? nestedObject(metadataTask, "automation") : undefined;
6007
+ if (metadataTaskAutomation)
6008
+ records.push(metadataTaskAutomation);
6009
+ return records;
6010
+ }
5845
6011
  function tagsFromValue(value) {
5846
6012
  if (Array.isArray(value))
5847
6013
  return value.map((entry) => String(entry).trim()).filter(Boolean);
@@ -5860,7 +6026,7 @@ function taskEventTags(records) {
5860
6026
  function taskRouteEligibility(data, metadata) {
5861
6027
  const records = taskEventRecords(data, metadata);
5862
6028
  const tags = taskEventTags(records);
5863
- const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed", "allowed"]);
6029
+ const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automationRecords(data, metadata), ["allowed"]);
5864
6030
  if (!hasRouteOptIn)
5865
6031
  return { eligible: false, reason: "missing explicit route opt-in", tags };
5866
6032
  const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
@@ -5940,7 +6106,7 @@ function permissionModeFromOpts(opts, provider) {
5940
6106
  return mode;
5941
6107
  }
5942
6108
  var create = program.command("create").description("create loops");
5943
- addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell"))))).action((name, opts) => {
6109
+ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell").option("--preflight", "check target executables/accounts before storing the loop"))))).action((name, opts) => {
5944
6110
  const store = new Store;
5945
6111
  try {
5946
6112
  const target = {
@@ -5951,13 +6117,15 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5951
6117
  timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
5952
6118
  account: accountFromOpts(opts)
5953
6119
  };
5954
- const loop = store.createLoop(baseCreateInput(name, opts, target));
5955
- print(publicLoop(loop), `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`);
6120
+ const input = baseCreateInput(name, opts, target);
6121
+ const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "command" }, { loopName: name }, { machine: input.machine }) : undefined;
6122
+ const loop = store.createLoop(input);
6123
+ printCreatedLoop(loop, `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
5956
6124
  } finally {
5957
6125
  store.close();
5958
6126
  }
5959
6127
  });
5960
- addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe"))))).action((name, opts) => {
6128
+ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe").option("--preflight", "check target executables/accounts before storing the loop"))))).action((name, opts) => {
5961
6129
  const provider = opts.provider;
5962
6130
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
5963
6131
  throw new Error("unsupported provider");
@@ -5983,13 +6151,15 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
5983
6151
  allowlist: allowlistFromOpts(opts),
5984
6152
  account: accountFromOpts(opts)
5985
6153
  };
5986
- const loop = store.createLoop(baseCreateInput(name, opts, target));
5987
- print(publicLoop(loop), `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`);
6154
+ const input = baseCreateInput(name, opts, target);
6155
+ const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "agent", provider }, { loopName: name }, { machine: input.machine }) : undefined;
6156
+ const loop = store.createLoop(input);
6157
+ printCreatedLoop(loop, `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
5988
6158
  } finally {
5989
6159
  store.close();
5990
6160
  }
5991
6161
  });
5992
- addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name")))).action((name, opts) => {
6162
+ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name").option("--preflight", "check workflow step executables/accounts before storing the loop")))).action((name, opts) => {
5993
6163
  const store = new Store;
5994
6164
  try {
5995
6165
  const workflow = store.requireWorkflow(opts.workflow);
@@ -5997,8 +6167,10 @@ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <na
5997
6167
  type: "workflow",
5998
6168
  workflowId: workflow.id
5999
6169
  };
6000
- const loop = store.createLoop(baseCreateInput(name, opts, target));
6001
- print(publicLoop(loop), `created workflow loop ${loop.id} (${loop.name}) workflow=${workflow.name} next=${loop.nextRunAt}`);
6170
+ const input = baseCreateInput(name, opts, target);
6171
+ const preflight = opts.preflight ? preflightStoredWorkflow(workflow, { name, type: "workflow", workflow: workflow.name }, { machine: input.machine }) : undefined;
6172
+ const loop = store.createLoop(input);
6173
+ printCreatedLoop(loop, `created workflow loop ${loop.id} (${loop.name}) workflow=${workflow.name} next=${loop.nextRunAt}`, preflight);
6002
6174
  } finally {
6003
6175
  store.close();
6004
6176
  }
@@ -6517,9 +6689,10 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6517
6689
  const store = new Store;
6518
6690
  try {
6519
6691
  const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
6520
- const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask).slice(0, Number(opts.maxActions));
6692
+ const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask);
6693
+ const selection = selectRouteItems(failures, Number(opts.maxActions), `health:${stableHash([opts.project, opts.taskList, opts.limit, Boolean(opts.includeInactive)])}`, (expectation) => expectation.recommendedTask.dedupeKey);
6521
6694
  const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "Loop Error Self Heal", "Deduped OpenLoops health expectation failures routed by loops health route-tasks.");
6522
- const actions = failures.map((expectation) => {
6695
+ const actions = selection.selected.map((expectation) => {
6523
6696
  const task = expectation.recommendedTask;
6524
6697
  const metadata = {
6525
6698
  source: "openloops.health.route-tasks",
@@ -6561,7 +6734,15 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6561
6734
  }
6562
6735
  return { action: "upserted", fingerprint: task.dedupeKey, task: JSON.parse(result.stdout || "{}") };
6563
6736
  });
6564
- const routed = { ok: actions.every((action) => action.action !== "upsert-failed"), inspected: report.summary.loops, failures: failures.length, actions };
6737
+ const routed = {
6738
+ ok: actions.every((action) => action.action !== "upsert-failed"),
6739
+ inspected: report.summary.loops,
6740
+ failures: failures.length,
6741
+ routing: selection.cursor,
6742
+ actions
6743
+ };
6744
+ if (!opts.dryRun && routed.ok)
6745
+ writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
6565
6746
  if (isJson() || opts.json)
6566
6747
  console.log(JSON.stringify(routed, null, 2));
6567
6748
  else {
@@ -6711,25 +6892,36 @@ function buildHygieneRouteTasks(store, opts) {
6711
6892
  hygiene.command("names").description("check or apply canonical machine-/repo-prefixed loop names").option("--apply", "rename loops in-place").option("--include-stopped", "include stopped loops").option("--include-inactive", "include stopped, expired, and archived loops").option("--limit <n>", "maximum loops to inspect", "1000").option("--json", "print JSON").action((opts) => {
6712
6893
  const store = new Store;
6713
6894
  try {
6714
- const backupPath = opts.apply ? backupLoopsDatabase("name-hygiene") : undefined;
6715
6895
  const report = buildNameHygieneReport(store, {
6716
- apply: Boolean(opts.apply),
6896
+ apply: false,
6717
6897
  includeStopped: Boolean(opts.includeStopped),
6718
6898
  includeInactive: Boolean(opts.includeInactive),
6719
6899
  limit: Number(opts.limit)
6720
6900
  });
6721
- const output = backupPath ? { ...report, backupPath } : report;
6901
+ let outputReport = report;
6902
+ const backupPath = opts.apply && report.changed > 0 ? backupLoopsDatabase("name-hygiene") : undefined;
6903
+ if (opts.apply && report.changed > 0) {
6904
+ outputReport = buildNameHygieneReport(store, {
6905
+ apply: true,
6906
+ includeStopped: Boolean(opts.includeStopped),
6907
+ includeInactive: Boolean(opts.includeInactive),
6908
+ limit: Number(opts.limit)
6909
+ });
6910
+ } else if (opts.apply) {
6911
+ outputReport = { ...report, applied: true };
6912
+ }
6913
+ const output = backupPath ? { ...outputReport, backupPath } : outputReport;
6722
6914
  if (isJson() || opts.json)
6723
6915
  console.log(JSON.stringify(output, null, 2));
6724
6916
  else {
6725
- console.log(`hygiene_names checked=${report.checked} changed=${report.changed} applied=${report.applied}`);
6917
+ console.log(`hygiene_names checked=${outputReport.checked} changed=${outputReport.changed} applied=${outputReport.applied}`);
6726
6918
  if (backupPath)
6727
6919
  console.log(`backup=${backupPath}`);
6728
- for (const change of report.changes.filter((entry) => entry.changed)) {
6729
- console.log(`${report.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
6920
+ for (const change of outputReport.changes.filter((entry) => entry.changed)) {
6921
+ console.log(`${outputReport.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
6730
6922
  }
6731
6923
  }
6732
- if (!report.ok && !report.applied)
6924
+ if (!outputReport.ok && !outputReport.applied)
6733
6925
  process.exitCode = 1;
6734
6926
  } finally {
6735
6927
  store.close();
@@ -6787,9 +6979,9 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
6787
6979
  limit: Number(opts.limit),
6788
6980
  scriptsDir: opts.scriptsDir
6789
6981
  });
6790
- const tasks = route.tasks.slice(0, Number(opts.maxActions));
6982
+ const selection = selectRouteItems(route.tasks, Number(opts.maxActions), `hygiene:${stableHash([opts.project, opts.taskList, checks, opts.limit, Boolean(opts.includeInactive), opts.scriptsDir ?? ""])}`, (task) => task.fingerprint);
6791
6983
  const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "OpenLoops Hygiene", "Deduped OpenLoops hygiene findings routed by loops hygiene route-tasks.");
6792
- const actions = tasks.map((task) => {
6984
+ const actions = selection.selected.map((task) => {
6793
6985
  if (opts.dryRun) {
6794
6986
  return { action: "would-upsert", check: task.check, title: task.title, fingerprint: task.fingerprint, priority: task.priority, metadata: task.metadata };
6795
6987
  }
@@ -6826,8 +7018,11 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
6826
7018
  checks,
6827
7019
  checked: route.checked,
6828
7020
  findings: route.findings,
7021
+ routing: selection.cursor,
6829
7022
  actions
6830
7023
  };
7024
+ if (!opts.dryRun && routed.ok)
7025
+ writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
6831
7026
  if (isJson() || opts.json)
6832
7027
  console.log(JSON.stringify(routed, null, 2));
6833
7028
  else {
@@ -2724,6 +2724,7 @@ function commandSpec(target) {
2724
2724
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2725
2725
  account: agentTarget.account,
2726
2726
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2727
+ nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
2727
2728
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2728
2729
  stdin: agentTarget.prompt,
2729
2730
  allowlist: agentTarget.allowlist
@@ -2805,6 +2806,9 @@ function remotePreflightScript(spec, metadata) {
2805
2806
  if (spec.preflightAnyOf?.length) {
2806
2807
  lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
2807
2808
  }
2809
+ if (spec.nativeAuthProfile?.provider === "codewith") {
2810
+ lines.push(`__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
2811
+ }
2808
2812
  return lines.join(`
2809
2813
  `);
2810
2814
  }
@@ -2820,6 +2824,29 @@ function transportEnv(opts) {
2820
2824
  env.PATH = normalizeExecutionPath(env);
2821
2825
  return env;
2822
2826
  }
2827
+ function preflightNativeAuthProfile(spec, env) {
2828
+ if (!spec.nativeAuthProfile)
2829
+ return;
2830
+ if (spec.nativeAuthProfile.provider !== "codewith")
2831
+ return;
2832
+ const result = spawnSync2(spec.command, ["profile", "list"], {
2833
+ encoding: "utf8",
2834
+ env,
2835
+ stdio: ["ignore", "pipe", "pipe"],
2836
+ timeout: 15000
2837
+ });
2838
+ if (result.error) {
2839
+ throw new Error(`codewith auth profile preflight failed: ${result.error.message}`);
2840
+ }
2841
+ if ((result.status ?? 1) !== 0) {
2842
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2843
+ throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
2844
+ }
2845
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
2846
+ if (!profiles.has(spec.nativeAuthProfile.profile)) {
2847
+ throw new Error(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`);
2848
+ }
2849
+ }
2823
2850
  function preflightRemoteSpec(spec, machine, metadata, opts) {
2824
2851
  const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2825
2852
  const result = spawnSync2(plan.command, plan.args, {
@@ -2961,6 +2988,7 @@ function preflightTarget(target, metadata = {}, opts = {}) {
2961
2988
  if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
2962
2989
  throw new Error(`none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
2963
2990
  }
2991
+ preflightNativeAuthProfile(spec, env);
2964
2992
  return {
2965
2993
  command: spec.command,
2966
2994
  accountProfile: spec.account?.profile,
@@ -3002,6 +3030,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3002
3030
  durationMs: 0
3003
3031
  };
3004
3032
  }
3033
+ try {
3034
+ preflightNativeAuthProfile(spec, env);
3035
+ } catch (err) {
3036
+ return {
3037
+ status: "failed",
3038
+ stdout: "",
3039
+ stderr: "",
3040
+ error: err instanceof Error ? err.message : String(err),
3041
+ startedAt,
3042
+ finishedAt: nowIso(),
3043
+ durationMs: 0
3044
+ };
3045
+ }
3005
3046
  const child = spawn(spec.command, spec.args, {
3006
3047
  cwd: spec.cwd,
3007
3048
  env,
@@ -3658,11 +3699,21 @@ async function executeWorkflow(store, workflow, opts = {}) {
3658
3699
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
3659
3700
  }
3660
3701
  function preflightWorkflow(workflow, opts = {}) {
3661
- return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
3662
- workflowId: workflow.id,
3663
- workflowName: workflow.name,
3664
- workflowStepId: step.id
3665
- }, opts));
3702
+ return workflowExecutionOrder(workflow).map((step) => {
3703
+ try {
3704
+ return {
3705
+ workflowStepId: step.id,
3706
+ ...preflightTarget(targetWithStepAccount(step), {
3707
+ workflowId: workflow.id,
3708
+ workflowName: workflow.name,
3709
+ workflowStepId: step.id
3710
+ }, opts)
3711
+ };
3712
+ } catch (error) {
3713
+ const message = error instanceof Error ? error.message : String(error);
3714
+ throw new Error(`workflow step ${step.id} preflight failed: ${message}`);
3715
+ }
3716
+ });
3666
3717
  }
3667
3718
  async function executeLoopTarget(store, loop, run, opts = {}) {
3668
3719
  if (loop.target.type !== "workflow") {
@@ -4419,7 +4470,7 @@ function enableStartup(result) {
4419
4470
  // package.json
4420
4471
  var package_default = {
4421
4472
  name: "@hasna/loops",
4422
- version: "0.3.20",
4473
+ version: "0.3.22",
4423
4474
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4424
4475
  type: "module",
4425
4476
  main: "dist/index.js",
package/dist/index.js CHANGED
@@ -2714,6 +2714,7 @@ function commandSpec(target) {
2714
2714
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2715
2715
  account: agentTarget.account,
2716
2716
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2717
+ nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
2717
2718
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2718
2719
  stdin: agentTarget.prompt,
2719
2720
  allowlist: agentTarget.allowlist
@@ -2795,6 +2796,9 @@ function remotePreflightScript(spec, metadata) {
2795
2796
  if (spec.preflightAnyOf?.length) {
2796
2797
  lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
2797
2798
  }
2799
+ if (spec.nativeAuthProfile?.provider === "codewith") {
2800
+ lines.push(`__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
2801
+ }
2798
2802
  return lines.join(`
2799
2803
  `);
2800
2804
  }
@@ -2810,6 +2814,29 @@ function transportEnv(opts) {
2810
2814
  env.PATH = normalizeExecutionPath(env);
2811
2815
  return env;
2812
2816
  }
2817
+ function preflightNativeAuthProfile(spec, env) {
2818
+ if (!spec.nativeAuthProfile)
2819
+ return;
2820
+ if (spec.nativeAuthProfile.provider !== "codewith")
2821
+ return;
2822
+ const result = spawnSync2(spec.command, ["profile", "list"], {
2823
+ encoding: "utf8",
2824
+ env,
2825
+ stdio: ["ignore", "pipe", "pipe"],
2826
+ timeout: 15000
2827
+ });
2828
+ if (result.error) {
2829
+ throw new Error(`codewith auth profile preflight failed: ${result.error.message}`);
2830
+ }
2831
+ if ((result.status ?? 1) !== 0) {
2832
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2833
+ throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
2834
+ }
2835
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
2836
+ if (!profiles.has(spec.nativeAuthProfile.profile)) {
2837
+ throw new Error(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`);
2838
+ }
2839
+ }
2813
2840
  function preflightRemoteSpec(spec, machine, metadata, opts) {
2814
2841
  const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2815
2842
  const result = spawnSync2(plan.command, plan.args, {
@@ -2951,6 +2978,7 @@ function preflightTarget(target, metadata = {}, opts = {}) {
2951
2978
  if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
2952
2979
  throw new Error(`none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
2953
2980
  }
2981
+ preflightNativeAuthProfile(spec, env);
2954
2982
  return {
2955
2983
  command: spec.command,
2956
2984
  accountProfile: spec.account?.profile,
@@ -2992,6 +3020,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
2992
3020
  durationMs: 0
2993
3021
  };
2994
3022
  }
3023
+ try {
3024
+ preflightNativeAuthProfile(spec, env);
3025
+ } catch (err) {
3026
+ return {
3027
+ status: "failed",
3028
+ stdout: "",
3029
+ stderr: "",
3030
+ error: err instanceof Error ? err.message : String(err),
3031
+ startedAt,
3032
+ finishedAt: nowIso(),
3033
+ durationMs: 0
3034
+ };
3035
+ }
2995
3036
  const child = spawn(spec.command, spec.args, {
2996
3037
  cwd: spec.cwd,
2997
3038
  env,
@@ -3648,11 +3689,21 @@ async function executeWorkflow(store, workflow, opts = {}) {
3648
3689
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
3649
3690
  }
3650
3691
  function preflightWorkflow(workflow, opts = {}) {
3651
- return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
3652
- workflowId: workflow.id,
3653
- workflowName: workflow.name,
3654
- workflowStepId: step.id
3655
- }, opts));
3692
+ return workflowExecutionOrder(workflow).map((step) => {
3693
+ try {
3694
+ return {
3695
+ workflowStepId: step.id,
3696
+ ...preflightTarget(targetWithStepAccount(step), {
3697
+ workflowId: workflow.id,
3698
+ workflowName: workflow.name,
3699
+ workflowStepId: step.id
3700
+ }, opts)
3701
+ };
3702
+ } catch (error) {
3703
+ const message = error instanceof Error ? error.message : String(error);
3704
+ throw new Error(`workflow step ${step.id} preflight failed: ${message}`);
3705
+ }
3706
+ });
3656
3707
  }
3657
3708
  async function executeLoopTarget(store, loop, run, opts = {}) {
3658
3709
  if (loop.target.type !== "workflow") {
@@ -4268,6 +4319,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4268
4319
  "You are the worker agent for a task-triggered OpenLoops workflow.",
4269
4320
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
4270
4321
  "Claim/start the task if appropriate, inspect the repository/project state, implement only the task scope, run focused validation, preserve unrelated user changes, and update the task with comments, evidence, changed files, commits, and blockers.",
4322
+ "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
4271
4323
  "Do not mark the task complete unless the work is genuinely done and validated.",
4272
4324
  "",
4273
4325
  `Task context JSON: ${compactJson(taskContext)}`
@@ -4279,6 +4331,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4279
4331
  "You are the verifier agent for a task-triggered OpenLoops workflow.",
4280
4332
  "Use fresh context. Inspect the task, repository state, commits, tests, and worker evidence. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
4281
4333
  "If the work is valid, record verification evidence in todos and mark/leave the task in the correct completed state according to the todos CLI. If it is not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
4334
+ "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
4282
4335
  "Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks.",
4283
4336
  "",
4284
4337
  `Task context JSON: ${compactJson(taskContext)}`
@@ -4850,6 +4903,7 @@ function recommendedTask(loop, run, failure, route) {
4850
4903
  `Status: ${run.status}`,
4851
4904
  `Classification: ${failure.classification}`,
4852
4905
  `Fingerprint: ${failure.fingerprint}`,
4906
+ `No-tmux routing: Do not dispatch or paste prompts into tmux panes; use task-triggered headless worker/verifier workflows only.`,
4853
4907
  route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4854
4908
  route.provider ? `Provider: ${route.provider}` : undefined,
4855
4909
  failure.evidence.error ? `Error:
package/dist/sdk/index.js CHANGED
@@ -2714,6 +2714,7 @@ function commandSpec(target) {
2714
2714
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2715
2715
  account: agentTarget.account,
2716
2716
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2717
+ nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
2717
2718
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2718
2719
  stdin: agentTarget.prompt,
2719
2720
  allowlist: agentTarget.allowlist
@@ -2795,6 +2796,9 @@ function remotePreflightScript(spec, metadata) {
2795
2796
  if (spec.preflightAnyOf?.length) {
2796
2797
  lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
2797
2798
  }
2799
+ if (spec.nativeAuthProfile?.provider === "codewith") {
2800
+ lines.push(`__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
2801
+ }
2798
2802
  return lines.join(`
2799
2803
  `);
2800
2804
  }
@@ -2810,6 +2814,29 @@ function transportEnv(opts) {
2810
2814
  env.PATH = normalizeExecutionPath(env);
2811
2815
  return env;
2812
2816
  }
2817
+ function preflightNativeAuthProfile(spec, env) {
2818
+ if (!spec.nativeAuthProfile)
2819
+ return;
2820
+ if (spec.nativeAuthProfile.provider !== "codewith")
2821
+ return;
2822
+ const result = spawnSync2(spec.command, ["profile", "list"], {
2823
+ encoding: "utf8",
2824
+ env,
2825
+ stdio: ["ignore", "pipe", "pipe"],
2826
+ timeout: 15000
2827
+ });
2828
+ if (result.error) {
2829
+ throw new Error(`codewith auth profile preflight failed: ${result.error.message}`);
2830
+ }
2831
+ if ((result.status ?? 1) !== 0) {
2832
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2833
+ throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
2834
+ }
2835
+ const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
2836
+ if (!profiles.has(spec.nativeAuthProfile.profile)) {
2837
+ throw new Error(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`);
2838
+ }
2839
+ }
2813
2840
  function preflightRemoteSpec(spec, machine, metadata, opts) {
2814
2841
  const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2815
2842
  const result = spawnSync2(plan.command, plan.args, {
@@ -2951,6 +2978,7 @@ function preflightTarget(target, metadata = {}, opts = {}) {
2951
2978
  if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
2952
2979
  throw new Error(`none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
2953
2980
  }
2981
+ preflightNativeAuthProfile(spec, env);
2954
2982
  return {
2955
2983
  command: spec.command,
2956
2984
  accountProfile: spec.account?.profile,
@@ -2992,6 +3020,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
2992
3020
  durationMs: 0
2993
3021
  };
2994
3022
  }
3023
+ try {
3024
+ preflightNativeAuthProfile(spec, env);
3025
+ } catch (err) {
3026
+ return {
3027
+ status: "failed",
3028
+ stdout: "",
3029
+ stderr: "",
3030
+ error: err instanceof Error ? err.message : String(err),
3031
+ startedAt,
3032
+ finishedAt: nowIso(),
3033
+ durationMs: 0
3034
+ };
3035
+ }
2995
3036
  const child = spawn(spec.command, spec.args, {
2996
3037
  cwd: spec.cwd,
2997
3038
  env,
@@ -3648,11 +3689,21 @@ async function executeWorkflow(store, workflow, opts = {}) {
3648
3689
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
3649
3690
  }
3650
3691
  function preflightWorkflow(workflow, opts = {}) {
3651
- return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
3652
- workflowId: workflow.id,
3653
- workflowName: workflow.name,
3654
- workflowStepId: step.id
3655
- }, opts));
3692
+ return workflowExecutionOrder(workflow).map((step) => {
3693
+ try {
3694
+ return {
3695
+ workflowStepId: step.id,
3696
+ ...preflightTarget(targetWithStepAccount(step), {
3697
+ workflowId: workflow.id,
3698
+ workflowName: workflow.name,
3699
+ workflowStepId: step.id
3700
+ }, opts)
3701
+ };
3702
+ } catch (error) {
3703
+ const message = error instanceof Error ? error.message : String(error);
3704
+ throw new Error(`workflow step ${step.id} preflight failed: ${message}`);
3705
+ }
3706
+ });
3656
3707
  }
3657
3708
  async function executeLoopTarget(store, loop, run, opts = {}) {
3658
3709
  if (loop.target.type !== "workflow") {
package/docs/USAGE.md CHANGED
@@ -49,6 +49,28 @@ Run a deterministic command every minute:
49
49
  loops create command repo-status --every 1m --cmd "git status --short" --cwd /path/to/repo
50
50
  ```
51
51
 
52
+ Validate the target before storing the loop:
53
+
54
+ ```bash
55
+ loops create command repo-status \
56
+ --every 1m \
57
+ --cmd git \
58
+ --no-shell \
59
+ --preflight
60
+ ```
61
+
62
+ `--preflight` is available on `loops create command`, `loops create agent`, and
63
+ `loops create workflow`. It checks target executables and configured account
64
+ profiles before the loop row is stored, so a missing command, provider binary,
65
+ OpenAccounts profile, or workflow step dependency fails without creating a
66
+ scheduled loop. Use `--json` with `--preflight` to capture stable machine-readable
67
+ preflight evidence.
68
+
69
+ For shell command loops, preflight can only verify the shell plus configured
70
+ accounts because the command string is interpreted later by the shell. Use
71
+ `--no-shell` or workflow command `args` when you need executable-level
72
+ validation before storing the loop.
73
+
52
74
  Run a Claude loop every morning:
53
75
 
54
76
  ```bash
@@ -333,7 +355,10 @@ inventories loops whose command still references `~/.hasna/loops/scripts`; use
333
355
  it as a migration gate before deleting local scripts. `hygiene route-tasks`
334
356
  upserts deduped Todos tasks for hygiene findings with stable fingerprints and
335
357
  `no_tmux_dispatch=true` metadata; use `--dry-run --json` before enabling it as a
336
- production loop.
358
+ production loop. Route commands store a small cursor in
359
+ `<LOOPS_DATA_DIR>/route-cursors.json` so bounded `--max-actions` runs advance
360
+ through all findings over repeated scheduled runs instead of reprocessing only
361
+ the first batch.
337
362
 
338
363
  Archive loops when retiring old automation but preserving history:
339
364
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",