@hasna/loops 0.3.18 → 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
@@ -318,9 +319,12 @@ them with `no_tmux_dispatch=true` metadata. Use `--dry-run --json` before
318
319
  turning it into a production loop.
319
320
 
320
321
  `hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
321
- renames only with `--apply`. `hygiene duplicates` groups loops with the same
322
- normalized name, cwd, and schedule. `hygiene scripts` inventories loops whose
323
- command still references `~/.hasna/loops/scripts`.
322
+ renames only with `--apply`. Apply mode writes a SQLite backup under
323
+ `<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
324
+ groups loops with the same normalized name, cwd, and schedule. `hygiene scripts`
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.
324
328
 
325
329
  Archive loops when retiring old automation but preserving history:
326
330
 
package/dist/cli/index.js CHANGED
@@ -2196,8 +2196,10 @@ class Store {
2196
2196
 
2197
2197
  // src/cli/index.ts
2198
2198
  import { createHash as createHash2 } from "crypto";
2199
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
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
+ import { join as join3 } from "path";
2202
+ import { Database as Database2 } from "bun:sqlite";
2201
2203
  import { Command } from "commander";
2202
2204
 
2203
2205
  // src/lib/format.ts
@@ -5089,7 +5091,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5089
5091
  // package.json
5090
5092
  var package_default = {
5091
5093
  name: "@hasna/loops",
5092
- version: "0.3.18",
5094
+ version: "0.3.20",
5093
5095
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5094
5096
  type: "module",
5095
5097
  main: "dist/index.js",
@@ -5742,6 +5744,23 @@ function ensureTodosTaskList(project, slug, name, description) {
5742
5744
  throw new Error(`todos task list not found after ensure: ${slug}`);
5743
5745
  return found.id;
5744
5746
  }
5747
+ function backupLoopsDatabase(reason) {
5748
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
5749
+ const backupDir = join3(dataDir(), "backups");
5750
+ mkdirSync5(backupDir, { recursive: true, mode: 448 });
5751
+ const backupPath = join3(backupDir, `loops.db.bak-${reason}-${stamp}`);
5752
+ const db = new Database2(dbPath(), { readonly: true });
5753
+ try {
5754
+ writeFileSync3(backupPath, db.serialize(), { mode: 384 });
5755
+ } finally {
5756
+ db.close();
5757
+ }
5758
+ return backupPath;
5759
+ }
5760
+ function stableHash(parts) {
5761
+ return createHash2("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
5762
+ `)).digest("hex").slice(0, 16);
5763
+ }
5745
5764
  function eventData(event) {
5746
5765
  const data = event.data;
5747
5766
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -6557,19 +6576,155 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
6557
6576
  }
6558
6577
  });
6559
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
+ }
6560
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) => {
6561
6712
  const store = new Store;
6562
6713
  try {
6714
+ const backupPath = opts.apply ? backupLoopsDatabase("name-hygiene") : undefined;
6563
6715
  const report = buildNameHygieneReport(store, {
6564
6716
  apply: Boolean(opts.apply),
6565
6717
  includeStopped: Boolean(opts.includeStopped),
6566
6718
  includeInactive: Boolean(opts.includeInactive),
6567
6719
  limit: Number(opts.limit)
6568
6720
  });
6721
+ const output = backupPath ? { ...report, backupPath } : report;
6569
6722
  if (isJson() || opts.json)
6570
- console.log(JSON.stringify(report, null, 2));
6723
+ console.log(JSON.stringify(output, null, 2));
6571
6724
  else {
6572
6725
  console.log(`hygiene_names checked=${report.checked} changed=${report.changed} applied=${report.applied}`);
6726
+ if (backupPath)
6727
+ console.log(`backup=${backupPath}`);
6573
6728
  for (const change of report.changes.filter((entry) => entry.changed)) {
6574
6729
  console.log(`${report.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
6575
6730
  }
@@ -6622,6 +6777,70 @@ hygiene.command("scripts").description("inventory loops still backed by local ~/
6622
6777
  store.close();
6623
6778
  }
6624
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
+ });
6625
6844
  program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
6626
6845
  program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
6627
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.18",
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,13 +322,18 @@ 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
328
- only renames when `--apply` is present. `hygiene duplicates` groups loops with
329
- the same normalized name, cwd, and schedule. `hygiene scripts` inventories loops
330
- whose command still references `~/.hasna/loops/scripts`; use it as a migration
331
- gate before deleting local scripts.
329
+ only renames when `--apply` is present. Apply mode writes a SQLite backup under
330
+ `<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
331
+ groups loops with the same normalized name, cwd, and schedule. `hygiene scripts`
332
+ inventories loops whose command still references `~/.hasna/loops/scripts`; use
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.
332
337
 
333
338
  Archive loops when retiring old automation but preserving history:
334
339
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.18",
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",