@hasna/loops 0.3.25 → 0.3.26

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
@@ -318,6 +318,16 @@ mutating path: it upserts deduped Todos tasks for failed expectations and marks
318
318
  them with `no_tmux_dispatch=true` metadata. Use `--dry-run --json` before
319
319
  turning it into a production loop.
320
320
 
321
+ Add `--evidence-dir <dir>` to `health route-tasks` or `hygiene route-tasks`
322
+ when the deterministic loop should write a durable JSON heartbeat/report in
323
+ addition to loop stdout. Add `--auto-route` only for task lists that are
324
+ intentionally connected to task-created worker/verifier automation; it appends
325
+ `auto:route`, sets route metadata, and lets OpenTodos/OpenEvents trigger the
326
+ existing headless workflow path when the finding has a cwd or
327
+ `--route-project-path` is provided. Findings with no routeable working
328
+ directory stay as tasks and record an `auto_route_skipped_reason`. Without
329
+ `--auto-route`, the command only creates or updates deduped tasks.
330
+
321
331
  `hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
322
332
  renames only with `--apply`. Apply mode writes a SQLite backup under
323
333
  `<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
package/dist/cli/index.js CHANGED
@@ -2195,7 +2195,7 @@ class Store {
2195
2195
  }
2196
2196
 
2197
2197
  // src/cli/index.ts
2198
- import { createHash as createHash2 } from "crypto";
2198
+ import { createHash as createHash2, randomUUID } from "crypto";
2199
2199
  import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2200
2200
  import { spawnSync as spawnSync5 } from "child_process";
2201
2201
  import { join as join3 } from "path";
@@ -5204,7 +5204,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5204
5204
  // package.json
5205
5205
  var package_default = {
5206
5206
  name: "@hasna/loops",
5207
- version: "0.3.25",
5207
+ version: "0.3.26",
5208
5208
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5209
5209
  type: "module",
5210
5210
  main: "dist/index.js",
@@ -5958,6 +5958,15 @@ function writeRouteCursor(key, lastFingerprint) {
5958
5958
  cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
5959
5959
  writeFileSync3(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
5960
5960
  }
5961
+ function writeRouteEvidence(kind, value, evidenceDir) {
5962
+ if (!evidenceDir)
5963
+ return;
5964
+ mkdirSync5(evidenceDir, { recursive: true, mode: 448 });
5965
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
5966
+ const evidencePath = join3(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
5967
+ writeFileSync3(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
5968
+ return evidencePath;
5969
+ }
5961
5970
  function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
5962
5971
  const total = items.length;
5963
5972
  const boundedMax = Math.max(0, Math.floor(Number.isFinite(maxActions) ? maxActions : 0));
@@ -5984,6 +5993,77 @@ function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
5984
5993
  }
5985
5994
  };
5986
5995
  }
5996
+ function taskAutoRoute(tags, base, opts) {
5997
+ if (!opts.autoRoute) {
5998
+ return {
5999
+ tags,
6000
+ metadata: {
6001
+ ...base,
6002
+ route_enabled: false,
6003
+ project_path: null,
6004
+ working_dir: null,
6005
+ auto_route_requested: false,
6006
+ auto_route_enabled: false,
6007
+ automation: {
6008
+ allowed: false,
6009
+ source: opts.source,
6010
+ kind: "task-created-worker-verifier"
6011
+ }
6012
+ },
6013
+ autoRoute: { requested: false, enabled: false }
6014
+ };
6015
+ }
6016
+ const projectPath = typeof base.cwd === "string" && base.cwd.trim() || typeof opts.routeProjectPath === "string" && opts.routeProjectPath.trim() || undefined;
6017
+ if (!projectPath) {
6018
+ return {
6019
+ tags,
6020
+ metadata: {
6021
+ ...base,
6022
+ route_enabled: false,
6023
+ project_path: null,
6024
+ working_dir: null,
6025
+ auto_route_requested: true,
6026
+ auto_route_enabled: false,
6027
+ auto_route_skipped_reason: "missing cwd or --route-project-path",
6028
+ automation: {
6029
+ allowed: false,
6030
+ source: opts.source,
6031
+ kind: "task-created-worker-verifier"
6032
+ }
6033
+ },
6034
+ autoRoute: {
6035
+ requested: true,
6036
+ enabled: false,
6037
+ skippedReason: "missing cwd or --route-project-path"
6038
+ }
6039
+ };
6040
+ }
6041
+ return {
6042
+ tags: [...new Set([...tags, "auto:route"])],
6043
+ metadata: {
6044
+ ...base,
6045
+ route_enabled: true,
6046
+ project_path: projectPath,
6047
+ working_dir: projectPath,
6048
+ auto_route_requested: true,
6049
+ auto_route_enabled: true,
6050
+ automation: {
6051
+ allowed: true,
6052
+ source: opts.source,
6053
+ kind: "task-created-worker-verifier"
6054
+ }
6055
+ },
6056
+ autoRoute: { requested: true, enabled: true }
6057
+ };
6058
+ }
6059
+ function routeTaskWorkingDirArgs(routeTask) {
6060
+ const workingDir = routeTask.autoRoute.enabled && typeof routeTask.metadata.working_dir === "string" ? routeTask.metadata.working_dir : undefined;
6061
+ return workingDir ? ["--working-dir", workingDir] : [];
6062
+ }
6063
+ function routeCursorKey(kind, parts, opts) {
6064
+ const routeMode = opts.autoRoute ? ["auto-route", opts.routeProjectPath ?? "cwd"] : [];
6065
+ return `${kind}:${stableHash([...parts, ...routeMode])}`;
6066
+ }
5987
6067
  function eventData(event) {
5988
6068
  const data = event.data;
5989
6069
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -6790,26 +6870,43 @@ var health = program.command("health").description("summarize loop health and la
6790
6870
  store.close();
6791
6871
  }
6792
6872
  });
6793
- health.command("route-tasks").description("upsert deduped todos tasks for failed loop health expectations").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "loop-error-self-heal").option("--limit <n>", "maximum loops to inspect", "200").option("--max-actions <n>", "maximum todos tasks to upsert", "5").option("--include-inactive", "also route stopped or expired loops").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
6873
+ health.command("route-tasks").description("upsert deduped todos tasks for failed loop health expectations").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "loop-error-self-heal").option("--limit <n>", "maximum loops to inspect", "200").option("--max-actions <n>", "maximum todos tasks to upsert", "5").option("--include-inactive", "also route stopped or expired loops").option("--auto-route", "opt routed tasks into task-created headless worker/verifier automation").option("--route-project-path <path>", "fallback project path for --auto-route when the failed loop has no cwd").option("--evidence-dir <path>", "write the route result JSON to this directory").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
6794
6874
  const store = new Store;
6795
6875
  try {
6796
6876
  const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
6797
6877
  const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask);
6798
- const selection = selectRouteItems(failures, Number(opts.maxActions), `health:${stableHash([opts.project, opts.taskList, opts.limit, Boolean(opts.includeInactive)])}`, (expectation) => expectation.recommendedTask.dedupeKey);
6878
+ const selection = selectRouteItems(failures, Number(opts.maxActions), routeCursorKey("health", [opts.project, opts.taskList, opts.limit, Boolean(opts.includeInactive)], {
6879
+ autoRoute: Boolean(opts.autoRoute),
6880
+ routeProjectPath: opts.routeProjectPath
6881
+ }), (expectation) => expectation.recommendedTask.dedupeKey);
6799
6882
  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.");
6800
6883
  const actions = selection.selected.map((expectation) => {
6801
6884
  const task = expectation.recommendedTask;
6802
- const metadata = {
6885
+ const routeTask = taskAutoRoute(task.tags, {
6803
6886
  source: "openloops.health.route-tasks",
6804
6887
  loop_id: expectation.loop.id,
6805
6888
  loop_name: expectation.loop.name,
6806
6889
  run_id: expectation.latestRun?.id,
6807
6890
  classification: expectation.failure?.classification,
6808
6891
  fingerprint: task.dedupeKey,
6892
+ cwd: expectation.route.cwd,
6893
+ provider: expectation.route.provider,
6809
6894
  no_tmux_dispatch: true
6810
- };
6895
+ }, {
6896
+ autoRoute: Boolean(opts.autoRoute),
6897
+ routeProjectPath: opts.routeProjectPath,
6898
+ source: "openloops.health.route-tasks"
6899
+ });
6811
6900
  if (opts.dryRun) {
6812
- return { action: "would-upsert", title: task.title, fingerprint: task.dedupeKey, priority: task.priority, metadata };
6901
+ return {
6902
+ action: "would-upsert",
6903
+ title: task.title,
6904
+ fingerprint: task.dedupeKey,
6905
+ priority: task.priority,
6906
+ tags: routeTask.tags,
6907
+ metadata: routeTask.metadata,
6908
+ autoRoute: routeTask.autoRoute
6909
+ };
6813
6910
  }
6814
6911
  const result = runLocalCommand("todos", [
6815
6912
  "--project",
@@ -6830,9 +6927,10 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6830
6927
  "--list",
6831
6928
  listId,
6832
6929
  "--tags",
6833
- task.tags.join(","),
6930
+ routeTask.tags.join(","),
6931
+ ...routeTaskWorkingDirArgs(routeTask),
6834
6932
  "--metadata-json",
6835
- JSON.stringify(metadata)
6933
+ JSON.stringify(routeTask.metadata)
6836
6934
  ]);
6837
6935
  if (!result.ok) {
6838
6936
  return { action: "upsert-failed", fingerprint: task.dedupeKey, error: result.stderr || result.error || result.stdout };
@@ -6846,12 +6944,16 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6846
6944
  routing: selection.cursor,
6847
6945
  actions
6848
6946
  };
6947
+ const evidencePath = writeRouteEvidence("health-route-tasks", routed, opts.evidenceDir);
6948
+ const output = evidencePath ? { ...routed, evidencePath } : routed;
6849
6949
  if (!opts.dryRun && routed.ok)
6850
6950
  writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
6851
6951
  if (isJson() || opts.json)
6852
- console.log(JSON.stringify(routed, null, 2));
6952
+ console.log(JSON.stringify(output, null, 2));
6853
6953
  else {
6854
- console.log(`health_route_tasks inspected=${routed.inspected} failures=${routed.failures} actions=${actions.length}`);
6954
+ console.log(`health_route_tasks inspected=${output.inspected} failures=${output.failures} actions=${actions.length}`);
6955
+ if (evidencePath)
6956
+ console.log(`evidence=${evidencePath}`);
6855
6957
  for (const action of actions)
6856
6958
  console.log(`${action.action} ${action.fingerprint}`);
6857
6959
  }
@@ -7074,7 +7176,7 @@ hygiene.command("scripts").description("inventory loops still backed by local ~/
7074
7176
  store.close();
7075
7177
  }
7076
7178
  });
7077
- hygiene.command("route-tasks").description("upsert deduped todos tasks for hygiene findings").option("--checks <list>", "comma-separated hygiene checks: names,duplicates,scripts,all", "all").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "openloops-hygiene").option("--limit <n>", "maximum loops to inspect", "1000").option("--max-actions <n>", "maximum todos tasks to upsert", "10").option("--scripts-dir <path>", "script directory to detect for script inventory").option("--include-inactive", "also route stopped, expired, or archived loops").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
7179
+ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygiene findings").option("--checks <list>", "comma-separated hygiene checks: names,duplicates,scripts,all", "all").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "openloops-hygiene").option("--limit <n>", "maximum loops to inspect", "1000").option("--max-actions <n>", "maximum todos tasks to upsert", "10").option("--scripts-dir <path>", "script directory to detect for script inventory").option("--include-inactive", "also route stopped, expired, or archived loops").option("--auto-route", "opt routed tasks into task-created headless worker/verifier automation").option("--route-project-path <path>", "fallback project path for --auto-route when the hygiene finding has no cwd").option("--evidence-dir <path>", "write the route result JSON to this directory").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
7078
7180
  const store = new Store;
7079
7181
  try {
7080
7182
  const checks = parseHygieneChecks(opts.checks);
@@ -7084,11 +7186,28 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
7084
7186
  limit: Number(opts.limit),
7085
7187
  scriptsDir: opts.scriptsDir
7086
7188
  });
7087
- 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);
7189
+ const selection = selectRouteItems(route.tasks, Number(opts.maxActions), routeCursorKey("hygiene", [opts.project, opts.taskList, checks, opts.limit, Boolean(opts.includeInactive), opts.scriptsDir ?? ""], {
7190
+ autoRoute: Boolean(opts.autoRoute),
7191
+ routeProjectPath: opts.routeProjectPath
7192
+ }), (task) => task.fingerprint);
7088
7193
  const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "OpenLoops Hygiene", "Deduped OpenLoops hygiene findings routed by loops hygiene route-tasks.");
7089
7194
  const actions = selection.selected.map((task) => {
7195
+ const routeTask = taskAutoRoute(task.tags, task.metadata, {
7196
+ autoRoute: Boolean(opts.autoRoute),
7197
+ routeProjectPath: opts.routeProjectPath,
7198
+ source: "openloops.hygiene.route-tasks"
7199
+ });
7090
7200
  if (opts.dryRun) {
7091
- return { action: "would-upsert", check: task.check, title: task.title, fingerprint: task.fingerprint, priority: task.priority, metadata: task.metadata };
7201
+ return {
7202
+ action: "would-upsert",
7203
+ check: task.check,
7204
+ title: task.title,
7205
+ fingerprint: task.fingerprint,
7206
+ priority: task.priority,
7207
+ tags: routeTask.tags,
7208
+ metadata: routeTask.metadata,
7209
+ autoRoute: routeTask.autoRoute
7210
+ };
7092
7211
  }
7093
7212
  const result = runLocalCommand("todos", [
7094
7213
  "--project",
@@ -7109,9 +7228,10 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
7109
7228
  "--list",
7110
7229
  listId,
7111
7230
  "--tags",
7112
- task.tags.join(","),
7231
+ routeTask.tags.join(","),
7232
+ ...routeTaskWorkingDirArgs(routeTask),
7113
7233
  "--metadata-json",
7114
- JSON.stringify(task.metadata)
7234
+ JSON.stringify(routeTask.metadata)
7115
7235
  ]);
7116
7236
  if (!result.ok) {
7117
7237
  return { action: "upsert-failed", check: task.check, fingerprint: task.fingerprint, error: result.stderr || result.error || result.stdout };
@@ -7126,12 +7246,16 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
7126
7246
  routing: selection.cursor,
7127
7247
  actions
7128
7248
  };
7249
+ const evidencePath = writeRouteEvidence("hygiene-route-tasks", routed, opts.evidenceDir);
7250
+ const output = evidencePath ? { ...routed, evidencePath } : routed;
7129
7251
  if (!opts.dryRun && routed.ok)
7130
7252
  writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
7131
7253
  if (isJson() || opts.json)
7132
- console.log(JSON.stringify(routed, null, 2));
7254
+ console.log(JSON.stringify(output, null, 2));
7133
7255
  else {
7134
- console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${routed.findings} actions=${actions.length}`);
7256
+ console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${output.findings} actions=${actions.length}`);
7257
+ if (evidencePath)
7258
+ console.log(`evidence=${evidencePath}`);
7135
7259
  for (const action of actions)
7136
7260
  console.log(`${action.action} ${action.fingerprint}`);
7137
7261
  }
@@ -4525,7 +4525,7 @@ function enableStartup(result) {
4525
4525
  // package.json
4526
4526
  var package_default = {
4527
4527
  name: "@hasna/loops",
4528
- version: "0.3.25",
4528
+ version: "0.3.26",
4529
4529
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4530
4530
  type: "module",
4531
4531
  main: "dist/index.js",
package/docs/USAGE.md CHANGED
@@ -338,6 +338,14 @@ Use `--dry-run --json` first when testing a new automation path. Routed tasks
338
338
  include the stable failure fingerprint, classification, loop id/name, and
339
339
  `no_tmux_dispatch=true` metadata.
340
340
 
341
+ Use `--evidence-dir <dir>` when a deterministic loop needs a compact JSON
342
+ heartbeat/report on disk. Use `--auto-route` only on task lists that should feed
343
+ the task-created headless worker/verifier workflow; it adds the `auto:route`
344
+ tag and route metadata when the finding has a cwd or `--route-project-path` is
345
+ provided. Findings with no routeable working directory remain plain tasks and
346
+ record an `auto_route_skipped_reason`. Without `--auto-route`, route commands
347
+ only upsert deduped tasks and do not launch agents.
348
+
341
349
  Failure classifications are: `rate_limit`, `auth`, `model_not_found`,
342
350
  `context_length`, `schema_response_format`, `node_init`, `timeout`, `sigsegv`,
343
351
  `skipped_previous_active`, and `unknown`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
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",