@hasna/loops 0.3.20 → 0.3.21

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
@@ -4757,6 +4757,7 @@ function recommendedTask(loop, run, failure, route) {
4757
4757
  `Status: ${run.status}`,
4758
4758
  `Classification: ${failure.classification}`,
4759
4759
  `Fingerprint: ${failure.fingerprint}`,
4760
+ `No-tmux routing: Do not dispatch or paste prompts into tmux panes; use task-triggered headless worker/verifier workflows only.`,
4760
4761
  route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4761
4762
  route.provider ? `Provider: ${route.provider}` : undefined,
4762
4763
  failure.evidence.error ? `Error:
@@ -5091,7 +5092,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5091
5092
  // package.json
5092
5093
  var package_default = {
5093
5094
  name: "@hasna/loops",
5094
- version: "0.3.20",
5095
+ version: "0.3.21",
5095
5096
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5096
5097
  type: "module",
5097
5098
  main: "dist/index.js",
@@ -5332,6 +5333,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5332
5333
  "You are the worker agent for a task-triggered OpenLoops workflow.",
5333
5334
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
5334
5335
  "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.",
5336
+ "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
5337
  "Do not mark the task complete unless the work is genuinely done and validated.",
5336
5338
  "",
5337
5339
  `Task context JSON: ${compactJson(taskContext)}`
@@ -5343,6 +5345,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5343
5345
  "You are the verifier agent for a task-triggered OpenLoops workflow.",
5344
5346
  "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
5347
  "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.",
5348
+ "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
5349
  "Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks.",
5347
5350
  "",
5348
5351
  `Task context JSON: ${compactJson(taskContext)}`
@@ -5761,6 +5764,53 @@ function stableHash(parts) {
5761
5764
  return createHash2("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
5762
5765
  `)).digest("hex").slice(0, 16);
5763
5766
  }
5767
+ function routeCursorsPath() {
5768
+ return join3(dataDir(), "route-cursors.json");
5769
+ }
5770
+ function readRouteCursors() {
5771
+ const path = routeCursorsPath();
5772
+ if (!existsSync3(path))
5773
+ return {};
5774
+ try {
5775
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
5776
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
5777
+ } catch {
5778
+ return {};
5779
+ }
5780
+ }
5781
+ function writeRouteCursor(key, lastFingerprint) {
5782
+ if (!lastFingerprint)
5783
+ return;
5784
+ const cursors = readRouteCursors();
5785
+ cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
5786
+ writeFileSync3(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
5787
+ }
5788
+ function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
5789
+ const total = items.length;
5790
+ const boundedMax = Math.max(0, Math.floor(Number.isFinite(maxActions) ? maxActions : 0));
5791
+ if (total === 0 || boundedMax === 0) {
5792
+ return { selected: [], cursor: { key: cursorKey, total, maxActions: boundedMax, startIndex: 0 } };
5793
+ }
5794
+ const cursors = readRouteCursors();
5795
+ const previousFingerprint = cursors[cursorKey]?.lastFingerprint;
5796
+ const previousIndex = previousFingerprint ? items.findIndex((item) => fingerprintOf(item) === previousFingerprint) : -1;
5797
+ const startIndex = previousIndex >= 0 ? (previousIndex + 1) % total : 0;
5798
+ const selected = [];
5799
+ const count = Math.min(boundedMax, total);
5800
+ for (let index = 0;index < count; index += 1)
5801
+ selected.push(items[(startIndex + index) % total]);
5802
+ return {
5803
+ selected,
5804
+ cursor: {
5805
+ key: cursorKey,
5806
+ total,
5807
+ maxActions: boundedMax,
5808
+ previousFingerprint,
5809
+ startIndex,
5810
+ lastFingerprint: selected.length ? fingerprintOf(selected[selected.length - 1]) : undefined
5811
+ }
5812
+ };
5813
+ }
5764
5814
  function eventData(event) {
5765
5815
  const data = event.data;
5766
5816
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -5842,6 +5892,36 @@ function booleanLike(value) {
5842
5892
  function hasTruthyField(records, keys) {
5843
5893
  return records.some((record) => keys.some((key) => booleanLike(record[key])));
5844
5894
  }
5895
+ function automationRecords(data, metadata) {
5896
+ const records = [];
5897
+ const dataAutomation = nestedObject(data, "automation");
5898
+ if (dataAutomation)
5899
+ records.push(dataAutomation);
5900
+ const dataTask = nestedObject(data, "task");
5901
+ const dataTaskAutomation = dataTask ? nestedObject(dataTask, "automation") : undefined;
5902
+ if (dataTaskAutomation)
5903
+ records.push(dataTaskAutomation);
5904
+ const dataPayload = nestedObject(data, "payload");
5905
+ const payloadAutomation = dataPayload ? nestedObject(dataPayload, "automation") : undefined;
5906
+ if (payloadAutomation)
5907
+ records.push(payloadAutomation);
5908
+ const payloadTask = dataPayload ? nestedObject(dataPayload, "task") : undefined;
5909
+ const payloadTaskAutomation = payloadTask ? nestedObject(payloadTask, "automation") : undefined;
5910
+ if (payloadTaskAutomation)
5911
+ records.push(payloadTaskAutomation);
5912
+ const dataMetadata = nestedObject(data, "metadata");
5913
+ const dataMetadataAutomation = dataMetadata ? nestedObject(dataMetadata, "automation") : undefined;
5914
+ if (dataMetadataAutomation)
5915
+ records.push(dataMetadataAutomation);
5916
+ const metadataAutomation = nestedObject(metadata, "automation");
5917
+ if (metadataAutomation)
5918
+ records.push(metadataAutomation);
5919
+ const metadataTask = nestedObject(metadata, "task");
5920
+ const metadataTaskAutomation = metadataTask ? nestedObject(metadataTask, "automation") : undefined;
5921
+ if (metadataTaskAutomation)
5922
+ records.push(metadataTaskAutomation);
5923
+ return records;
5924
+ }
5845
5925
  function tagsFromValue(value) {
5846
5926
  if (Array.isArray(value))
5847
5927
  return value.map((entry) => String(entry).trim()).filter(Boolean);
@@ -5860,7 +5940,7 @@ function taskEventTags(records) {
5860
5940
  function taskRouteEligibility(data, metadata) {
5861
5941
  const records = taskEventRecords(data, metadata);
5862
5942
  const tags = taskEventTags(records);
5863
- const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed", "allowed"]);
5943
+ const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automationRecords(data, metadata), ["allowed"]);
5864
5944
  if (!hasRouteOptIn)
5865
5945
  return { eligible: false, reason: "missing explicit route opt-in", tags };
5866
5946
  const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
@@ -6517,9 +6597,10 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6517
6597
  const store = new Store;
6518
6598
  try {
6519
6599
  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));
6600
+ const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask);
6601
+ const selection = selectRouteItems(failures, Number(opts.maxActions), `health:${stableHash([opts.project, opts.taskList, opts.limit, Boolean(opts.includeInactive)])}`, (expectation) => expectation.recommendedTask.dedupeKey);
6521
6602
  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) => {
6603
+ const actions = selection.selected.map((expectation) => {
6523
6604
  const task = expectation.recommendedTask;
6524
6605
  const metadata = {
6525
6606
  source: "openloops.health.route-tasks",
@@ -6561,7 +6642,15 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6561
6642
  }
6562
6643
  return { action: "upserted", fingerprint: task.dedupeKey, task: JSON.parse(result.stdout || "{}") };
6563
6644
  });
6564
- const routed = { ok: actions.every((action) => action.action !== "upsert-failed"), inspected: report.summary.loops, failures: failures.length, actions };
6645
+ const routed = {
6646
+ ok: actions.every((action) => action.action !== "upsert-failed"),
6647
+ inspected: report.summary.loops,
6648
+ failures: failures.length,
6649
+ routing: selection.cursor,
6650
+ actions
6651
+ };
6652
+ if (!opts.dryRun && routed.ok)
6653
+ writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
6565
6654
  if (isJson() || opts.json)
6566
6655
  console.log(JSON.stringify(routed, null, 2));
6567
6656
  else {
@@ -6711,25 +6800,36 @@ function buildHygieneRouteTasks(store, opts) {
6711
6800
  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
6801
  const store = new Store;
6713
6802
  try {
6714
- const backupPath = opts.apply ? backupLoopsDatabase("name-hygiene") : undefined;
6715
6803
  const report = buildNameHygieneReport(store, {
6716
- apply: Boolean(opts.apply),
6804
+ apply: false,
6717
6805
  includeStopped: Boolean(opts.includeStopped),
6718
6806
  includeInactive: Boolean(opts.includeInactive),
6719
6807
  limit: Number(opts.limit)
6720
6808
  });
6721
- const output = backupPath ? { ...report, backupPath } : report;
6809
+ let outputReport = report;
6810
+ const backupPath = opts.apply && report.changed > 0 ? backupLoopsDatabase("name-hygiene") : undefined;
6811
+ if (opts.apply && report.changed > 0) {
6812
+ outputReport = buildNameHygieneReport(store, {
6813
+ apply: true,
6814
+ includeStopped: Boolean(opts.includeStopped),
6815
+ includeInactive: Boolean(opts.includeInactive),
6816
+ limit: Number(opts.limit)
6817
+ });
6818
+ } else if (opts.apply) {
6819
+ outputReport = { ...report, applied: true };
6820
+ }
6821
+ const output = backupPath ? { ...outputReport, backupPath } : outputReport;
6722
6822
  if (isJson() || opts.json)
6723
6823
  console.log(JSON.stringify(output, null, 2));
6724
6824
  else {
6725
- console.log(`hygiene_names checked=${report.checked} changed=${report.changed} applied=${report.applied}`);
6825
+ console.log(`hygiene_names checked=${outputReport.checked} changed=${outputReport.changed} applied=${outputReport.applied}`);
6726
6826
  if (backupPath)
6727
6827
  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}`);
6828
+ for (const change of outputReport.changes.filter((entry) => entry.changed)) {
6829
+ console.log(`${outputReport.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
6730
6830
  }
6731
6831
  }
6732
- if (!report.ok && !report.applied)
6832
+ if (!outputReport.ok && !outputReport.applied)
6733
6833
  process.exitCode = 1;
6734
6834
  } finally {
6735
6835
  store.close();
@@ -6787,9 +6887,9 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
6787
6887
  limit: Number(opts.limit),
6788
6888
  scriptsDir: opts.scriptsDir
6789
6889
  });
6790
- const tasks = route.tasks.slice(0, Number(opts.maxActions));
6890
+ 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
6891
  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) => {
6892
+ const actions = selection.selected.map((task) => {
6793
6893
  if (opts.dryRun) {
6794
6894
  return { action: "would-upsert", check: task.check, title: task.title, fingerprint: task.fingerprint, priority: task.priority, metadata: task.metadata };
6795
6895
  }
@@ -6826,8 +6926,11 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
6826
6926
  checks,
6827
6927
  checked: route.checked,
6828
6928
  findings: route.findings,
6929
+ routing: selection.cursor,
6829
6930
  actions
6830
6931
  };
6932
+ if (!opts.dryRun && routed.ok)
6933
+ writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
6831
6934
  if (isJson() || opts.json)
6832
6935
  console.log(JSON.stringify(routed, null, 2));
6833
6936
  else {
@@ -4419,7 +4419,7 @@ function enableStartup(result) {
4419
4419
  // package.json
4420
4420
  var package_default = {
4421
4421
  name: "@hasna/loops",
4422
- version: "0.3.20",
4422
+ version: "0.3.21",
4423
4423
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4424
4424
  type: "module",
4425
4425
  main: "dist/index.js",
package/dist/index.js CHANGED
@@ -4268,6 +4268,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4268
4268
  "You are the worker agent for a task-triggered OpenLoops workflow.",
4269
4269
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
4270
4270
  "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.",
4271
+ "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
4272
  "Do not mark the task complete unless the work is genuinely done and validated.",
4272
4273
  "",
4273
4274
  `Task context JSON: ${compactJson(taskContext)}`
@@ -4279,6 +4280,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
4279
4280
  "You are the verifier agent for a task-triggered OpenLoops workflow.",
4280
4281
  "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
4282
  "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.",
4283
+ "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
4284
  "Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks.",
4283
4285
  "",
4284
4286
  `Task context JSON: ${compactJson(taskContext)}`
@@ -4850,6 +4852,7 @@ function recommendedTask(loop, run, failure, route) {
4850
4852
  `Status: ${run.status}`,
4851
4853
  `Classification: ${failure.classification}`,
4852
4854
  `Fingerprint: ${failure.fingerprint}`,
4855
+ `No-tmux routing: Do not dispatch or paste prompts into tmux panes; use task-triggered headless worker/verifier workflows only.`,
4853
4856
  route.cwd ? `Route cwd: ${route.cwd}` : undefined,
4854
4857
  route.provider ? `Provider: ${route.provider}` : undefined,
4855
4858
  failure.evidence.error ? `Error:
package/docs/USAGE.md CHANGED
@@ -333,7 +333,10 @@ inventories loops whose command still references `~/.hasna/loops/scripts`; use
333
333
  it as a migration gate before deleting local scripts. `hygiene route-tasks`
334
334
  upserts deduped Todos tasks for hygiene findings with stable fingerprints and
335
335
  `no_tmux_dispatch=true` metadata; use `--dry-run --json` before enabling it as a
336
- production loop.
336
+ production loop. Route commands store a small cursor in
337
+ `<LOOPS_DATA_DIR>/route-cursors.json` so bounded `--max-actions` runs advance
338
+ through all findings over repeated scheduled runs instead of reprocessing only
339
+ the first batch.
337
340
 
338
341
  Archive loops when retiring old automation but preserving history:
339
342
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.20",
3
+ "version": "0.3.21",
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",