@hasna/loops 0.3.17 → 0.3.19

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,9 +318,10 @@ them with `no_tmux_dispatch=true` metadata. Use `--dry-run --json` before
318
318
  turning it into a production loop.
319
319
 
320
320
  `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`.
321
+ renames only with `--apply`. Apply mode writes a SQLite backup under
322
+ `<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
323
+ groups loops with the same normalized name, cwd, and schedule. `hygiene scripts`
324
+ inventories loops whose command still references `~/.hasna/loops/scripts`.
324
325
 
325
326
  Archive loops when retiring old automation but preserving history:
326
327
 
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
@@ -4640,6 +4642,7 @@ function runDoctor(store) {
4640
4642
  // src/lib/health.ts
4641
4643
  import { createHash } from "crypto";
4642
4644
  var EVIDENCE_CHARS = 2000;
4645
+ var FINGERPRINT_EVIDENCE_CHARS = 120;
4643
4646
  var CLASSIFICATIONS = [
4644
4647
  "rate_limit",
4645
4648
  "auth",
@@ -4668,6 +4671,15 @@ function stableFingerprint(parts) {
4668
4671
  return createHash("sha256").update(parts.join(`
4669
4672
  `)).digest("hex").slice(0, 16);
4670
4673
  }
4674
+ function stableFailureFingerprint(run, classification) {
4675
+ return stableFingerprint([
4676
+ run.loopId,
4677
+ classification,
4678
+ String(run.status),
4679
+ String(run.exitCode ?? ""),
4680
+ (run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
4681
+ ]);
4682
+ }
4671
4683
  function healthRun(run) {
4672
4684
  return {
4673
4685
  ...run,
@@ -4701,14 +4713,7 @@ function classifyRunFailure(run) {
4701
4713
  classification = "sigsegv";
4702
4714
  return {
4703
4715
  classification,
4704
- fingerprint: stableFingerprint([
4705
- run.loopId,
4706
- run.loopName,
4707
- run.status,
4708
- classification,
4709
- String(run.exitCode ?? ""),
4710
- (run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
4711
- ]),
4716
+ fingerprint: stableFailureFingerprint(run, classification),
4712
4717
  evidence: {
4713
4718
  error: bounded(run.error),
4714
4719
  stdout: bounded(run.stdout),
@@ -4824,7 +4829,7 @@ function expectationForLoop(store, loop) {
4824
4829
  };
4825
4830
  }
4826
4831
  function buildHealthReport(store, opts = {}) {
4827
- const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4832
+ const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 }).filter((loop) => opts.includeInactive || loop.status === "active" || loop.status === "paused");
4828
4833
  const expectations = loops.map((loop) => expectationForLoop(store, loop));
4829
4834
  const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4830
4835
  for (const expectation of expectations) {
@@ -4928,8 +4933,9 @@ function canonicalName(loop) {
4928
4933
  newName: name
4929
4934
  };
4930
4935
  }
4931
- function ensureUnique(changes) {
4932
- const used = new Set;
4936
+ function ensureUnique(changes, existingNames = []) {
4937
+ const oldNames = new Set(changes.map((change) => change.oldName));
4938
+ const used = new Set([...existingNames].filter((name) => !oldNames.has(name)));
4933
4939
  for (const change of changes) {
4934
4940
  let candidate = change.newName;
4935
4941
  if (!used.has(candidate)) {
@@ -4959,6 +4965,7 @@ function managedLoops(store, opts) {
4959
4965
  return loops.filter((loop) => loop.status === "active" || loop.status === "paused");
4960
4966
  }
4961
4967
  function buildNameHygieneReport(store, opts = {}) {
4968
+ const allLoops = store.listLoops({ includeArchived: true, limit: 1e4 });
4962
4969
  const changes = managedLoops(store, opts).map((loop) => {
4963
4970
  const canonical = canonicalName(loop);
4964
4971
  return {
@@ -4967,8 +4974,9 @@ function buildNameHygieneReport(store, opts = {}) {
4967
4974
  changed: loop.name !== canonical.newName
4968
4975
  };
4969
4976
  });
4970
- ensureUnique(changes);
4977
+ ensureUnique(changes, allLoops.map((loop) => loop.name));
4971
4978
  const changed = changes.filter((change) => change.changed);
4979
+ const conflicts = changes.filter((change) => allLoops.some((loop) => loop.name === change.newName && loop.id !== change.id));
4972
4980
  if (opts.apply) {
4973
4981
  for (const change of changed)
4974
4982
  store.renameLoop(change.id, change.newName);
@@ -4979,7 +4987,8 @@ function buildNameHygieneReport(store, opts = {}) {
4979
4987
  applied: Boolean(opts.apply),
4980
4988
  checked: changes.length,
4981
4989
  changed: changed.length,
4982
- changes
4990
+ changes,
4991
+ conflicts
4983
4992
  };
4984
4993
  }
4985
4994
  function baseName(name) {
@@ -5033,14 +5042,33 @@ function commandText(loop) {
5033
5042
  return "";
5034
5043
  return [loop.target.command, ...loop.target.args ?? []].join(" ");
5035
5044
  }
5045
+ function scriptNeedles(scriptsDir) {
5046
+ const home = process.env.HOME ?? "/home/hasna";
5047
+ const normalized = scriptsDir.replace(/\/+$/g, "");
5048
+ const values = [
5049
+ normalized,
5050
+ `${normalized}/`,
5051
+ "~/.hasna/loops/scripts",
5052
+ "~/.hasna/loops/scripts/",
5053
+ "$HOME/.hasna/loops/scripts",
5054
+ "$HOME/.hasna/loops/scripts/",
5055
+ "${HOME}/.hasna/loops/scripts",
5056
+ "${HOME}/.hasna/loops/scripts/",
5057
+ `${home}/.hasna/loops/scripts`,
5058
+ `${home}/.hasna/loops/scripts/`,
5059
+ "/.hasna/loops/scripts/"
5060
+ ];
5061
+ return [...new Set(values)];
5062
+ }
5036
5063
  function buildScriptInventoryReport(store, opts = {}) {
5037
5064
  const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
5065
+ const needles = scriptNeedles(scriptsDir);
5038
5066
  const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5039
5067
  const scriptBacked = loops.map((loop) => {
5040
5068
  const text = commandText(loop);
5041
5069
  if (!text)
5042
5070
  return;
5043
- const matches = [scriptsDir, "/.hasna/loops/scripts/"].filter((needle) => text.includes(needle));
5071
+ const matches = needles.filter((needle) => text.includes(needle));
5044
5072
  if (!matches.length)
5045
5073
  return;
5046
5074
  return {
@@ -5063,7 +5091,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5063
5091
  // package.json
5064
5092
  var package_default = {
5065
5093
  name: "@hasna/loops",
5066
- version: "0.3.17",
5094
+ version: "0.3.19",
5067
5095
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5068
5096
  type: "module",
5069
5097
  main: "dist/index.js",
@@ -5433,7 +5461,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
5433
5461
  ].join(`
5434
5462
  `);
5435
5463
  return {
5436
- name: input.name ?? `bounded-agent-${stableIndex(seed, 65535).toString(16)}-worker-verifier`,
5464
+ name: input.name ?? `bounded-agent-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
5437
5465
  description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
5438
5466
  version: 1,
5439
5467
  steps: [
@@ -5716,6 +5744,19 @@ function ensureTodosTaskList(project, slug, name, description) {
5716
5744
  throw new Error(`todos task list not found after ensure: ${slug}`);
5717
5745
  return found.id;
5718
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
+ }
5719
5760
  function eventData(event) {
5720
5761
  const data = event.data;
5721
5762
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -6468,10 +6509,10 @@ var health = program.command("health").description("summarize loop health and la
6468
6509
  store.close();
6469
6510
  }
6470
6511
  });
6471
- 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("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
6512
+ 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) => {
6472
6513
  const store = new Store;
6473
6514
  try {
6474
- const report = buildHealthReport(store, { limit: Number(opts.limit) });
6515
+ const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
6475
6516
  const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask).slice(0, Number(opts.maxActions));
6476
6517
  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.");
6477
6518
  const actions = failures.map((expectation) => {
@@ -6534,16 +6575,20 @@ var hygiene = program.command("hygiene").description("deterministic OpenLoops hy
6534
6575
  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) => {
6535
6576
  const store = new Store;
6536
6577
  try {
6578
+ const backupPath = opts.apply ? backupLoopsDatabase("name-hygiene") : undefined;
6537
6579
  const report = buildNameHygieneReport(store, {
6538
6580
  apply: Boolean(opts.apply),
6539
6581
  includeStopped: Boolean(opts.includeStopped),
6540
6582
  includeInactive: Boolean(opts.includeInactive),
6541
6583
  limit: Number(opts.limit)
6542
6584
  });
6585
+ const output = backupPath ? { ...report, backupPath } : report;
6543
6586
  if (isJson() || opts.json)
6544
- console.log(JSON.stringify(report, null, 2));
6587
+ console.log(JSON.stringify(output, null, 2));
6545
6588
  else {
6546
6589
  console.log(`hygiene_names checked=${report.checked} changed=${report.changed} applied=${report.applied}`);
6590
+ if (backupPath)
6591
+ console.log(`backup=${backupPath}`);
6547
6592
  for (const change of report.changes.filter((entry) => entry.changed)) {
6548
6593
  console.log(`${report.applied ? "renamed" : "would-rename"} ${change.id} ${change.oldName} -> ${change.newName}`);
6549
6594
  }
@@ -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.17",
4422
+ version: "0.3.19",
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
@@ -4397,7 +4397,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
4397
4397
  ].join(`
4398
4398
  `);
4399
4399
  return {
4400
- name: input.name ?? `bounded-agent-${stableIndex(seed, 65535).toString(16)}-worker-verifier`,
4400
+ name: input.name ?? `bounded-agent-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
4401
4401
  description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
4402
4402
  version: 1,
4403
4403
  steps: [
@@ -4735,6 +4735,7 @@ function runDoctor(store) {
4735
4735
  // src/lib/health.ts
4736
4736
  import { createHash } from "crypto";
4737
4737
  var EVIDENCE_CHARS = 2000;
4738
+ var FINGERPRINT_EVIDENCE_CHARS = 120;
4738
4739
  var CLASSIFICATIONS = [
4739
4740
  "rate_limit",
4740
4741
  "auth",
@@ -4763,6 +4764,15 @@ function stableFingerprint(parts) {
4763
4764
  return createHash("sha256").update(parts.join(`
4764
4765
  `)).digest("hex").slice(0, 16);
4765
4766
  }
4767
+ function stableFailureFingerprint(run, classification) {
4768
+ return stableFingerprint([
4769
+ run.loopId,
4770
+ classification,
4771
+ String(run.status),
4772
+ String(run.exitCode ?? ""),
4773
+ (run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
4774
+ ]);
4775
+ }
4766
4776
  function healthRun(run) {
4767
4777
  return {
4768
4778
  ...run,
@@ -4796,14 +4806,7 @@ function classifyRunFailure(run) {
4796
4806
  classification = "sigsegv";
4797
4807
  return {
4798
4808
  classification,
4799
- fingerprint: stableFingerprint([
4800
- run.loopId,
4801
- run.loopName,
4802
- run.status,
4803
- classification,
4804
- String(run.exitCode ?? ""),
4805
- (run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
4806
- ]),
4809
+ fingerprint: stableFailureFingerprint(run, classification),
4807
4810
  evidence: {
4808
4811
  error: bounded(run.error),
4809
4812
  stdout: bounded(run.stdout),
@@ -4919,7 +4922,7 @@ function expectationForLoop(store, loop) {
4919
4922
  };
4920
4923
  }
4921
4924
  function buildHealthReport(store, opts = {}) {
4922
- const loops2 = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
4925
+ const loops2 = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 }).filter((loop) => opts.includeInactive || loop.status === "active" || loop.status === "paused");
4923
4926
  const expectations = loops2.map((loop) => expectationForLoop(store, loop));
4924
4927
  const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
4925
4928
  for (const expectation of expectations) {
@@ -5022,8 +5025,9 @@ function canonicalName(loop) {
5022
5025
  newName: name
5023
5026
  };
5024
5027
  }
5025
- function ensureUnique(changes) {
5026
- const used = new Set;
5028
+ function ensureUnique(changes, existingNames = []) {
5029
+ const oldNames = new Set(changes.map((change) => change.oldName));
5030
+ const used = new Set([...existingNames].filter((name) => !oldNames.has(name)));
5027
5031
  for (const change of changes) {
5028
5032
  let candidate = change.newName;
5029
5033
  if (!used.has(candidate)) {
@@ -5053,6 +5057,7 @@ function managedLoops(store, opts) {
5053
5057
  return loops2.filter((loop) => loop.status === "active" || loop.status === "paused");
5054
5058
  }
5055
5059
  function buildNameHygieneReport(store, opts = {}) {
5060
+ const allLoops = store.listLoops({ includeArchived: true, limit: 1e4 });
5056
5061
  const changes = managedLoops(store, opts).map((loop) => {
5057
5062
  const canonical = canonicalName(loop);
5058
5063
  return {
@@ -5061,8 +5066,9 @@ function buildNameHygieneReport(store, opts = {}) {
5061
5066
  changed: loop.name !== canonical.newName
5062
5067
  };
5063
5068
  });
5064
- ensureUnique(changes);
5069
+ ensureUnique(changes, allLoops.map((loop) => loop.name));
5065
5070
  const changed = changes.filter((change) => change.changed);
5071
+ const conflicts = changes.filter((change) => allLoops.some((loop) => loop.name === change.newName && loop.id !== change.id));
5066
5072
  if (opts.apply) {
5067
5073
  for (const change of changed)
5068
5074
  store.renameLoop(change.id, change.newName);
@@ -5073,7 +5079,8 @@ function buildNameHygieneReport(store, opts = {}) {
5073
5079
  applied: Boolean(opts.apply),
5074
5080
  checked: changes.length,
5075
5081
  changed: changed.length,
5076
- changes
5082
+ changes,
5083
+ conflicts
5077
5084
  };
5078
5085
  }
5079
5086
  function baseName(name) {
@@ -5127,14 +5134,33 @@ function commandText(loop) {
5127
5134
  return "";
5128
5135
  return [loop.target.command, ...loop.target.args ?? []].join(" ");
5129
5136
  }
5137
+ function scriptNeedles(scriptsDir) {
5138
+ const home = process.env.HOME ?? "/home/hasna";
5139
+ const normalized = scriptsDir.replace(/\/+$/g, "");
5140
+ const values = [
5141
+ normalized,
5142
+ `${normalized}/`,
5143
+ "~/.hasna/loops/scripts",
5144
+ "~/.hasna/loops/scripts/",
5145
+ "$HOME/.hasna/loops/scripts",
5146
+ "$HOME/.hasna/loops/scripts/",
5147
+ "${HOME}/.hasna/loops/scripts",
5148
+ "${HOME}/.hasna/loops/scripts/",
5149
+ `${home}/.hasna/loops/scripts`,
5150
+ `${home}/.hasna/loops/scripts/`,
5151
+ "/.hasna/loops/scripts/"
5152
+ ];
5153
+ return [...new Set(values)];
5154
+ }
5130
5155
  function buildScriptInventoryReport(store, opts = {}) {
5131
5156
  const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
5157
+ const needles = scriptNeedles(scriptsDir);
5132
5158
  const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
5133
5159
  const scriptBacked = loops2.map((loop) => {
5134
5160
  const text = commandText(loop);
5135
5161
  if (!text)
5136
5162
  return;
5137
- const matches = [scriptsDir, "/.hasna/loops/scripts/"].filter((needle) => text.includes(needle));
5163
+ const matches = needles.filter((needle) => text.includes(needle));
5138
5164
  if (!matches.length)
5139
5165
  return;
5140
5166
  return {
@@ -66,5 +66,6 @@ export declare function classifyRunFailure(run: LoopRun): RunFailureSignal | und
66
66
  export declare function expectationForLoop(store: Store, loop: Loop): LoopExpectationResult;
67
67
  export declare function buildHealthReport(store: Store, opts?: {
68
68
  includeArchived?: boolean;
69
+ includeInactive?: boolean;
69
70
  limit?: number;
70
71
  }): LoopsHealthReport;
@@ -16,6 +16,7 @@ export interface NameHygieneReport {
16
16
  checked: number;
17
17
  changed: number;
18
18
  changes: NameHygieneChange[];
19
+ conflicts: NameHygieneChange[];
19
20
  }
20
21
  export interface DuplicateOverlapGroup {
21
22
  key: string;
package/docs/USAGE.md CHANGED
@@ -325,10 +325,11 @@ loops hygiene scripts --json
325
325
  ```
326
326
 
327
327
  `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.
328
+ only renames when `--apply` is present. Apply mode writes a SQLite backup under
329
+ `<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
330
+ groups loops with the same normalized name, cwd, and schedule. `hygiene scripts`
331
+ inventories loops whose command still references `~/.hasna/loops/scripts`; use
332
+ it as a migration gate before deleting local scripts.
332
333
 
333
334
  Archive loops when retiring old automation but preserving history:
334
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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",