@hasna/loops 0.3.19 → 0.3.20

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
@@ -309,6 +309,7 @@ loops health route-tasks --project ~/.hasna/loops --task-list loop-error-self-he
309
309
  loops hygiene names --json
310
310
  loops hygiene duplicates --json
311
311
  loops hygiene scripts --json
312
+ loops hygiene route-tasks --checks duplicates,scripts --project ~/.hasna/loops --task-list openloops-hygiene
312
313
  ```
313
314
 
314
315
  `health` and `expectations` classify latest-run failures with stable
@@ -322,6 +323,8 @@ renames only with `--apply`. Apply mode writes a SQLite backup under
322
323
  `<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
323
324
  groups loops with the same normalized name, cwd, and schedule. `hygiene scripts`
324
325
  inventories loops whose command still references `~/.hasna/loops/scripts`.
326
+ `hygiene route-tasks` upserts deduped Todos tasks for hygiene findings with
327
+ stable fingerprints and `no_tmux_dispatch=true` metadata.
325
328
 
326
329
  Archive loops when retiring old automation but preserving history:
327
330
 
package/dist/cli/index.js CHANGED
@@ -5091,7 +5091,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5091
5091
  // package.json
5092
5092
  var package_default = {
5093
5093
  name: "@hasna/loops",
5094
- version: "0.3.19",
5094
+ version: "0.3.20",
5095
5095
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5096
5096
  type: "module",
5097
5097
  main: "dist/index.js",
@@ -5757,6 +5757,10 @@ function backupLoopsDatabase(reason) {
5757
5757
  }
5758
5758
  return backupPath;
5759
5759
  }
5760
+ function stableHash(parts) {
5761
+ return createHash2("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
5762
+ `)).digest("hex").slice(0, 16);
5763
+ }
5760
5764
  function eventData(event) {
5761
5765
  const data = event.data;
5762
5766
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -6572,6 +6576,138 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6572
6576
  }
6573
6577
  });
6574
6578
  var hygiene = program.command("hygiene").description("deterministic OpenLoops hygiene checks and safe repairs");
6579
+ var HYGIENE_CHECKS = ["names", "duplicates", "scripts"];
6580
+ function parseHygieneChecks(value) {
6581
+ if (!value || value === "all")
6582
+ return HYGIENE_CHECKS;
6583
+ const checks = value.split(",").map((entry) => entry.trim()).filter(Boolean);
6584
+ const invalid = checks.filter((entry) => !HYGIENE_CHECKS.includes(entry));
6585
+ if (invalid.length > 0)
6586
+ throw new Error(`invalid hygiene check(s): ${invalid.join(", ")}`);
6587
+ return [...new Set(checks)];
6588
+ }
6589
+ function buildHygieneRouteTasks(store, opts) {
6590
+ const checked = { names: 0, duplicates: 0, scripts: 0 };
6591
+ const tasks = [];
6592
+ const limit = opts.limit ?? 1000;
6593
+ if (opts.checks.includes("names")) {
6594
+ const report = buildNameHygieneReport(store, { includeInactive: opts.includeInactive, limit });
6595
+ checked.names = report.checked;
6596
+ for (const change of report.changes.filter((entry) => entry.changed)) {
6597
+ const fingerprint = `openloops:hygiene:names:${change.id}:${stableHash([change.oldName, change.newName])}`;
6598
+ tasks.push({
6599
+ check: "names",
6600
+ title: `OpenLoops hygiene: rename loop ${change.oldName}`,
6601
+ description: [
6602
+ `OpenLoops name hygiene found a non-canonical loop name.`,
6603
+ `Loop: ${change.oldName} (${change.id})`,
6604
+ `Expected name: ${change.newName}`,
6605
+ `Scope: ${change.scope} / ${change.scopeSlug}`,
6606
+ `Fingerprint: ${fingerprint}`,
6607
+ "",
6608
+ "Acceptance:",
6609
+ "- Confirm the canonical name is correct for the loop scope.",
6610
+ "- Rename through OpenLoops CLI/API so ids, schedules, run history, and metadata are preserved.",
6611
+ "- Do not dispatch work by tmux."
6612
+ ].join(`
6613
+ `),
6614
+ priority: "low",
6615
+ tags: ["openloops", "hygiene", "name-hygiene"],
6616
+ fingerprint,
6617
+ metadata: {
6618
+ source: "openloops.hygiene.route-tasks",
6619
+ check: "names",
6620
+ loop_id: change.id,
6621
+ old_name: change.oldName,
6622
+ new_name: change.newName,
6623
+ scope: change.scope,
6624
+ scope_slug: change.scopeSlug,
6625
+ no_tmux_dispatch: true
6626
+ }
6627
+ });
6628
+ }
6629
+ }
6630
+ if (opts.checks.includes("duplicates")) {
6631
+ const report = buildDuplicateOverlapReport(store, { includeInactive: opts.includeInactive, limit });
6632
+ checked.duplicates = report.checked;
6633
+ for (const group of report.groups) {
6634
+ const loopIds = group.loops.map((loop) => loop.id).sort();
6635
+ const fingerprint = `openloops:hygiene:duplicates:${stableHash([group.key, loopIds])}`;
6636
+ tasks.push({
6637
+ check: "duplicates",
6638
+ title: `OpenLoops hygiene: duplicate/overlapping loops - ${group.baseName}`,
6639
+ description: [
6640
+ `OpenLoops duplicate/overlap hygiene found multiple loops with the same normalized name, cwd, and schedule.`,
6641
+ `Base name: ${group.baseName}`,
6642
+ group.cwd ? `Cwd: ${group.cwd}` : undefined,
6643
+ `Schedule: ${group.schedule}`,
6644
+ `Fingerprint: ${fingerprint}`,
6645
+ "",
6646
+ "Loops:",
6647
+ ...group.loops.map((loop) => `- ${loop.id} ${loop.status} ${loop.name}`),
6648
+ "",
6649
+ "Acceptance:",
6650
+ "- Decide the authoritative active loop.",
6651
+ "- Archive or retarget superseded loops through OpenLoops CLI/API while preserving history.",
6652
+ "- Do not dispatch work by tmux."
6653
+ ].filter(Boolean).join(`
6654
+ `),
6655
+ priority: group.loops.some((loop) => loop.status === "active") ? "medium" : "low",
6656
+ tags: ["openloops", "hygiene", "duplicate-overlap"],
6657
+ fingerprint,
6658
+ metadata: {
6659
+ source: "openloops.hygiene.route-tasks",
6660
+ check: "duplicates",
6661
+ base_name: group.baseName,
6662
+ cwd: group.cwd,
6663
+ schedule: group.schedule,
6664
+ loop_ids: loopIds,
6665
+ no_tmux_dispatch: true
6666
+ }
6667
+ });
6668
+ }
6669
+ }
6670
+ if (opts.checks.includes("scripts")) {
6671
+ const report = buildScriptInventoryReport(store, { includeInactive: opts.includeInactive, limit, scriptsDir: opts.scriptsDir });
6672
+ checked.scripts = report.checked;
6673
+ for (const loop of report.loops) {
6674
+ const fingerprint = `openloops:hygiene:scripts:${loop.id}:${stableHash([loop.command])}`;
6675
+ tasks.push({
6676
+ check: "scripts",
6677
+ title: `OpenLoops hygiene: replace script-backed loop ${loop.name}`,
6678
+ description: [
6679
+ `OpenLoops script inventory found a loop still backed by a local script command.`,
6680
+ `Loop: ${loop.name} (${loop.id})`,
6681
+ `Status: ${loop.status}`,
6682
+ loop.cwd ? `Cwd: ${loop.cwd}` : undefined,
6683
+ `Command: ${loop.command}`,
6684
+ `Fingerprint: ${fingerprint}`,
6685
+ "",
6686
+ "Acceptance:",
6687
+ "- Replace this loop with a package-level CLI/API/template abstraction when one exists.",
6688
+ "- If no abstraction exists, create/update the owning repo task instead of adding another local script.",
6689
+ "- Archive superseded loops through OpenLoops CLI/API and preserve history.",
6690
+ "- Do not dispatch work by tmux."
6691
+ ].filter(Boolean).join(`
6692
+ `),
6693
+ priority: loop.status === "active" ? "medium" : "low",
6694
+ tags: ["openloops", "hygiene", "script-backed-loop"],
6695
+ fingerprint,
6696
+ metadata: {
6697
+ source: "openloops.hygiene.route-tasks",
6698
+ check: "scripts",
6699
+ loop_id: loop.id,
6700
+ loop_name: loop.name,
6701
+ loop_status: loop.status,
6702
+ cwd: loop.cwd,
6703
+ script_matches: loop.scriptMatches,
6704
+ no_tmux_dispatch: true
6705
+ }
6706
+ });
6707
+ }
6708
+ }
6709
+ return { checked, findings: tasks.length, tasks };
6710
+ }
6575
6711
  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) => {
6576
6712
  const store = new Store;
6577
6713
  try {
@@ -6641,6 +6777,70 @@ hygiene.command("scripts").description("inventory loops still backed by local ~/
6641
6777
  store.close();
6642
6778
  }
6643
6779
  });
6780
+ 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) => {
6781
+ const store = new Store;
6782
+ try {
6783
+ const checks = parseHygieneChecks(opts.checks);
6784
+ const route = buildHygieneRouteTasks(store, {
6785
+ checks,
6786
+ includeInactive: Boolean(opts.includeInactive),
6787
+ limit: Number(opts.limit),
6788
+ scriptsDir: opts.scriptsDir
6789
+ });
6790
+ const tasks = route.tasks.slice(0, Number(opts.maxActions));
6791
+ 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) => {
6793
+ if (opts.dryRun) {
6794
+ return { action: "would-upsert", check: task.check, title: task.title, fingerprint: task.fingerprint, priority: task.priority, metadata: task.metadata };
6795
+ }
6796
+ const result = runLocalCommand("todos", [
6797
+ "--project",
6798
+ opts.project,
6799
+ "--json",
6800
+ "task",
6801
+ "upsert",
6802
+ "--fingerprint",
6803
+ task.fingerprint,
6804
+ "--title",
6805
+ task.title,
6806
+ "-d",
6807
+ task.description,
6808
+ "--priority",
6809
+ task.priority,
6810
+ "--status",
6811
+ "pending",
6812
+ "--list",
6813
+ listId,
6814
+ "--tags",
6815
+ task.tags.join(","),
6816
+ "--metadata-json",
6817
+ JSON.stringify(task.metadata)
6818
+ ]);
6819
+ if (!result.ok) {
6820
+ return { action: "upsert-failed", check: task.check, fingerprint: task.fingerprint, error: result.stderr || result.error || result.stdout };
6821
+ }
6822
+ return { action: "upserted", check: task.check, fingerprint: task.fingerprint, task: JSON.parse(result.stdout || "{}") };
6823
+ });
6824
+ const routed = {
6825
+ ok: actions.every((action) => action.action !== "upsert-failed"),
6826
+ checks,
6827
+ checked: route.checked,
6828
+ findings: route.findings,
6829
+ actions
6830
+ };
6831
+ if (isJson() || opts.json)
6832
+ console.log(JSON.stringify(routed, null, 2));
6833
+ else {
6834
+ console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${routed.findings} actions=${actions.length}`);
6835
+ for (const action of actions)
6836
+ console.log(`${action.action} ${action.fingerprint}`);
6837
+ }
6838
+ if (!routed.ok)
6839
+ process.exitCode = 1;
6840
+ } finally {
6841
+ store.close();
6842
+ }
6843
+ });
6644
6844
  program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
6645
6845
  program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
6646
6846
  program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
@@ -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.19",
4422
+ version: "0.3.20",
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/docs/USAGE.md CHANGED
@@ -322,6 +322,7 @@ loops hygiene names --json
322
322
  loops hygiene names --apply
323
323
  loops hygiene duplicates --json
324
324
  loops hygiene scripts --json
325
+ loops hygiene route-tasks --checks names,duplicates,scripts --dry-run --json
325
326
  ```
326
327
 
327
328
  `hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
@@ -329,7 +330,10 @@ only renames when `--apply` is present. Apply mode writes a SQLite backup under
329
330
  `<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
330
331
  groups loops with the same normalized name, cwd, and schedule. `hygiene scripts`
331
332
  inventories loops whose command still references `~/.hasna/loops/scripts`; use
332
- it as a migration gate before deleting local scripts.
333
+ it as a migration gate before deleting local scripts. `hygiene route-tasks`
334
+ upserts deduped Todos tasks for hygiene findings with stable fingerprints and
335
+ `no_tmux_dispatch=true` metadata; use `--dry-run --json` before enabling it as a
336
+ production loop.
333
337
 
334
338
  Archive loops when retiring old automation but preserving history:
335
339
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
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",