@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 +7 -3
- package/dist/cli/index.js +222 -3
- package/dist/daemon/index.js +1 -1
- package/docs/USAGE.md +9 -4
- package/package.json +1 -1
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`.
|
|
322
|
-
|
|
323
|
-
|
|
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.
|
|
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(
|
|
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"));
|
package/dist/daemon/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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