@hasna/loops 0.3.19 → 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 +6 -0
- package/dist/cli/index.js +315 -12
- package/dist/daemon/index.js +1 -1
- package/dist/index.js +3 -0
- package/docs/USAGE.md +8 -1
- 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
|
|
@@ -322,6 +323,11 @@ 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. 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.
|
|
325
331
|
|
|
326
332
|
Archive loops when retiring old automation but preserving history:
|
|
327
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.
|
|
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)}`
|
|
@@ -5757,6 +5760,57 @@ function backupLoopsDatabase(reason) {
|
|
|
5757
5760
|
}
|
|
5758
5761
|
return backupPath;
|
|
5759
5762
|
}
|
|
5763
|
+
function stableHash(parts) {
|
|
5764
|
+
return createHash2("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
|
|
5765
|
+
`)).digest("hex").slice(0, 16);
|
|
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
|
+
}
|
|
5760
5814
|
function eventData(event) {
|
|
5761
5815
|
const data = event.data;
|
|
5762
5816
|
if (data && typeof data === "object" && !Array.isArray(data))
|
|
@@ -5838,6 +5892,36 @@ function booleanLike(value) {
|
|
|
5838
5892
|
function hasTruthyField(records, keys) {
|
|
5839
5893
|
return records.some((record) => keys.some((key) => booleanLike(record[key])));
|
|
5840
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
|
+
}
|
|
5841
5925
|
function tagsFromValue(value) {
|
|
5842
5926
|
if (Array.isArray(value))
|
|
5843
5927
|
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
@@ -5856,7 +5940,7 @@ function taskEventTags(records) {
|
|
|
5856
5940
|
function taskRouteEligibility(data, metadata) {
|
|
5857
5941
|
const records = taskEventRecords(data, metadata);
|
|
5858
5942
|
const tags = taskEventTags(records);
|
|
5859
|
-
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"]);
|
|
5860
5944
|
if (!hasRouteOptIn)
|
|
5861
5945
|
return { eligible: false, reason: "missing explicit route opt-in", tags };
|
|
5862
5946
|
const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
|
|
@@ -6513,9 +6597,10 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
6513
6597
|
const store = new Store;
|
|
6514
6598
|
try {
|
|
6515
6599
|
const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
|
|
6516
|
-
const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask)
|
|
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);
|
|
6517
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.");
|
|
6518
|
-
const actions =
|
|
6603
|
+
const actions = selection.selected.map((expectation) => {
|
|
6519
6604
|
const task = expectation.recommendedTask;
|
|
6520
6605
|
const metadata = {
|
|
6521
6606
|
source: "openloops.health.route-tasks",
|
|
@@ -6557,7 +6642,15 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
6557
6642
|
}
|
|
6558
6643
|
return { action: "upserted", fingerprint: task.dedupeKey, task: JSON.parse(result.stdout || "{}") };
|
|
6559
6644
|
});
|
|
6560
|
-
const routed = {
|
|
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);
|
|
6561
6654
|
if (isJson() || opts.json)
|
|
6562
6655
|
console.log(JSON.stringify(routed, null, 2));
|
|
6563
6656
|
else {
|
|
@@ -6572,28 +6665,171 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
6572
6665
|
}
|
|
6573
6666
|
});
|
|
6574
6667
|
var hygiene = program.command("hygiene").description("deterministic OpenLoops hygiene checks and safe repairs");
|
|
6668
|
+
var HYGIENE_CHECKS = ["names", "duplicates", "scripts"];
|
|
6669
|
+
function parseHygieneChecks(value) {
|
|
6670
|
+
if (!value || value === "all")
|
|
6671
|
+
return HYGIENE_CHECKS;
|
|
6672
|
+
const checks = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
6673
|
+
const invalid = checks.filter((entry) => !HYGIENE_CHECKS.includes(entry));
|
|
6674
|
+
if (invalid.length > 0)
|
|
6675
|
+
throw new Error(`invalid hygiene check(s): ${invalid.join(", ")}`);
|
|
6676
|
+
return [...new Set(checks)];
|
|
6677
|
+
}
|
|
6678
|
+
function buildHygieneRouteTasks(store, opts) {
|
|
6679
|
+
const checked = { names: 0, duplicates: 0, scripts: 0 };
|
|
6680
|
+
const tasks = [];
|
|
6681
|
+
const limit = opts.limit ?? 1000;
|
|
6682
|
+
if (opts.checks.includes("names")) {
|
|
6683
|
+
const report = buildNameHygieneReport(store, { includeInactive: opts.includeInactive, limit });
|
|
6684
|
+
checked.names = report.checked;
|
|
6685
|
+
for (const change of report.changes.filter((entry) => entry.changed)) {
|
|
6686
|
+
const fingerprint = `openloops:hygiene:names:${change.id}:${stableHash([change.oldName, change.newName])}`;
|
|
6687
|
+
tasks.push({
|
|
6688
|
+
check: "names",
|
|
6689
|
+
title: `OpenLoops hygiene: rename loop ${change.oldName}`,
|
|
6690
|
+
description: [
|
|
6691
|
+
`OpenLoops name hygiene found a non-canonical loop name.`,
|
|
6692
|
+
`Loop: ${change.oldName} (${change.id})`,
|
|
6693
|
+
`Expected name: ${change.newName}`,
|
|
6694
|
+
`Scope: ${change.scope} / ${change.scopeSlug}`,
|
|
6695
|
+
`Fingerprint: ${fingerprint}`,
|
|
6696
|
+
"",
|
|
6697
|
+
"Acceptance:",
|
|
6698
|
+
"- Confirm the canonical name is correct for the loop scope.",
|
|
6699
|
+
"- Rename through OpenLoops CLI/API so ids, schedules, run history, and metadata are preserved.",
|
|
6700
|
+
"- Do not dispatch work by tmux."
|
|
6701
|
+
].join(`
|
|
6702
|
+
`),
|
|
6703
|
+
priority: "low",
|
|
6704
|
+
tags: ["openloops", "hygiene", "name-hygiene"],
|
|
6705
|
+
fingerprint,
|
|
6706
|
+
metadata: {
|
|
6707
|
+
source: "openloops.hygiene.route-tasks",
|
|
6708
|
+
check: "names",
|
|
6709
|
+
loop_id: change.id,
|
|
6710
|
+
old_name: change.oldName,
|
|
6711
|
+
new_name: change.newName,
|
|
6712
|
+
scope: change.scope,
|
|
6713
|
+
scope_slug: change.scopeSlug,
|
|
6714
|
+
no_tmux_dispatch: true
|
|
6715
|
+
}
|
|
6716
|
+
});
|
|
6717
|
+
}
|
|
6718
|
+
}
|
|
6719
|
+
if (opts.checks.includes("duplicates")) {
|
|
6720
|
+
const report = buildDuplicateOverlapReport(store, { includeInactive: opts.includeInactive, limit });
|
|
6721
|
+
checked.duplicates = report.checked;
|
|
6722
|
+
for (const group of report.groups) {
|
|
6723
|
+
const loopIds = group.loops.map((loop) => loop.id).sort();
|
|
6724
|
+
const fingerprint = `openloops:hygiene:duplicates:${stableHash([group.key, loopIds])}`;
|
|
6725
|
+
tasks.push({
|
|
6726
|
+
check: "duplicates",
|
|
6727
|
+
title: `OpenLoops hygiene: duplicate/overlapping loops - ${group.baseName}`,
|
|
6728
|
+
description: [
|
|
6729
|
+
`OpenLoops duplicate/overlap hygiene found multiple loops with the same normalized name, cwd, and schedule.`,
|
|
6730
|
+
`Base name: ${group.baseName}`,
|
|
6731
|
+
group.cwd ? `Cwd: ${group.cwd}` : undefined,
|
|
6732
|
+
`Schedule: ${group.schedule}`,
|
|
6733
|
+
`Fingerprint: ${fingerprint}`,
|
|
6734
|
+
"",
|
|
6735
|
+
"Loops:",
|
|
6736
|
+
...group.loops.map((loop) => `- ${loop.id} ${loop.status} ${loop.name}`),
|
|
6737
|
+
"",
|
|
6738
|
+
"Acceptance:",
|
|
6739
|
+
"- Decide the authoritative active loop.",
|
|
6740
|
+
"- Archive or retarget superseded loops through OpenLoops CLI/API while preserving history.",
|
|
6741
|
+
"- Do not dispatch work by tmux."
|
|
6742
|
+
].filter(Boolean).join(`
|
|
6743
|
+
`),
|
|
6744
|
+
priority: group.loops.some((loop) => loop.status === "active") ? "medium" : "low",
|
|
6745
|
+
tags: ["openloops", "hygiene", "duplicate-overlap"],
|
|
6746
|
+
fingerprint,
|
|
6747
|
+
metadata: {
|
|
6748
|
+
source: "openloops.hygiene.route-tasks",
|
|
6749
|
+
check: "duplicates",
|
|
6750
|
+
base_name: group.baseName,
|
|
6751
|
+
cwd: group.cwd,
|
|
6752
|
+
schedule: group.schedule,
|
|
6753
|
+
loop_ids: loopIds,
|
|
6754
|
+
no_tmux_dispatch: true
|
|
6755
|
+
}
|
|
6756
|
+
});
|
|
6757
|
+
}
|
|
6758
|
+
}
|
|
6759
|
+
if (opts.checks.includes("scripts")) {
|
|
6760
|
+
const report = buildScriptInventoryReport(store, { includeInactive: opts.includeInactive, limit, scriptsDir: opts.scriptsDir });
|
|
6761
|
+
checked.scripts = report.checked;
|
|
6762
|
+
for (const loop of report.loops) {
|
|
6763
|
+
const fingerprint = `openloops:hygiene:scripts:${loop.id}:${stableHash([loop.command])}`;
|
|
6764
|
+
tasks.push({
|
|
6765
|
+
check: "scripts",
|
|
6766
|
+
title: `OpenLoops hygiene: replace script-backed loop ${loop.name}`,
|
|
6767
|
+
description: [
|
|
6768
|
+
`OpenLoops script inventory found a loop still backed by a local script command.`,
|
|
6769
|
+
`Loop: ${loop.name} (${loop.id})`,
|
|
6770
|
+
`Status: ${loop.status}`,
|
|
6771
|
+
loop.cwd ? `Cwd: ${loop.cwd}` : undefined,
|
|
6772
|
+
`Command: ${loop.command}`,
|
|
6773
|
+
`Fingerprint: ${fingerprint}`,
|
|
6774
|
+
"",
|
|
6775
|
+
"Acceptance:",
|
|
6776
|
+
"- Replace this loop with a package-level CLI/API/template abstraction when one exists.",
|
|
6777
|
+
"- If no abstraction exists, create/update the owning repo task instead of adding another local script.",
|
|
6778
|
+
"- Archive superseded loops through OpenLoops CLI/API and preserve history.",
|
|
6779
|
+
"- Do not dispatch work by tmux."
|
|
6780
|
+
].filter(Boolean).join(`
|
|
6781
|
+
`),
|
|
6782
|
+
priority: loop.status === "active" ? "medium" : "low",
|
|
6783
|
+
tags: ["openloops", "hygiene", "script-backed-loop"],
|
|
6784
|
+
fingerprint,
|
|
6785
|
+
metadata: {
|
|
6786
|
+
source: "openloops.hygiene.route-tasks",
|
|
6787
|
+
check: "scripts",
|
|
6788
|
+
loop_id: loop.id,
|
|
6789
|
+
loop_name: loop.name,
|
|
6790
|
+
loop_status: loop.status,
|
|
6791
|
+
cwd: loop.cwd,
|
|
6792
|
+
script_matches: loop.scriptMatches,
|
|
6793
|
+
no_tmux_dispatch: true
|
|
6794
|
+
}
|
|
6795
|
+
});
|
|
6796
|
+
}
|
|
6797
|
+
}
|
|
6798
|
+
return { checked, findings: tasks.length, tasks };
|
|
6799
|
+
}
|
|
6575
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) => {
|
|
6576
6801
|
const store = new Store;
|
|
6577
6802
|
try {
|
|
6578
|
-
const backupPath = opts.apply ? backupLoopsDatabase("name-hygiene") : undefined;
|
|
6579
6803
|
const report = buildNameHygieneReport(store, {
|
|
6580
|
-
apply:
|
|
6804
|
+
apply: false,
|
|
6581
6805
|
includeStopped: Boolean(opts.includeStopped),
|
|
6582
6806
|
includeInactive: Boolean(opts.includeInactive),
|
|
6583
6807
|
limit: Number(opts.limit)
|
|
6584
6808
|
});
|
|
6585
|
-
|
|
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;
|
|
6586
6822
|
if (isJson() || opts.json)
|
|
6587
6823
|
console.log(JSON.stringify(output, null, 2));
|
|
6588
6824
|
else {
|
|
6589
|
-
console.log(`hygiene_names checked=${
|
|
6825
|
+
console.log(`hygiene_names checked=${outputReport.checked} changed=${outputReport.changed} applied=${outputReport.applied}`);
|
|
6590
6826
|
if (backupPath)
|
|
6591
6827
|
console.log(`backup=${backupPath}`);
|
|
6592
|
-
for (const change of
|
|
6593
|
-
console.log(`${
|
|
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}`);
|
|
6594
6830
|
}
|
|
6595
6831
|
}
|
|
6596
|
-
if (!
|
|
6832
|
+
if (!outputReport.ok && !outputReport.applied)
|
|
6597
6833
|
process.exitCode = 1;
|
|
6598
6834
|
} finally {
|
|
6599
6835
|
store.close();
|
|
@@ -6641,6 +6877,73 @@ hygiene.command("scripts").description("inventory loops still backed by local ~/
|
|
|
6641
6877
|
store.close();
|
|
6642
6878
|
}
|
|
6643
6879
|
});
|
|
6880
|
+
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) => {
|
|
6881
|
+
const store = new Store;
|
|
6882
|
+
try {
|
|
6883
|
+
const checks = parseHygieneChecks(opts.checks);
|
|
6884
|
+
const route = buildHygieneRouteTasks(store, {
|
|
6885
|
+
checks,
|
|
6886
|
+
includeInactive: Boolean(opts.includeInactive),
|
|
6887
|
+
limit: Number(opts.limit),
|
|
6888
|
+
scriptsDir: opts.scriptsDir
|
|
6889
|
+
});
|
|
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);
|
|
6891
|
+
const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "OpenLoops Hygiene", "Deduped OpenLoops hygiene findings routed by loops hygiene route-tasks.");
|
|
6892
|
+
const actions = selection.selected.map((task) => {
|
|
6893
|
+
if (opts.dryRun) {
|
|
6894
|
+
return { action: "would-upsert", check: task.check, title: task.title, fingerprint: task.fingerprint, priority: task.priority, metadata: task.metadata };
|
|
6895
|
+
}
|
|
6896
|
+
const result = runLocalCommand("todos", [
|
|
6897
|
+
"--project",
|
|
6898
|
+
opts.project,
|
|
6899
|
+
"--json",
|
|
6900
|
+
"task",
|
|
6901
|
+
"upsert",
|
|
6902
|
+
"--fingerprint",
|
|
6903
|
+
task.fingerprint,
|
|
6904
|
+
"--title",
|
|
6905
|
+
task.title,
|
|
6906
|
+
"-d",
|
|
6907
|
+
task.description,
|
|
6908
|
+
"--priority",
|
|
6909
|
+
task.priority,
|
|
6910
|
+
"--status",
|
|
6911
|
+
"pending",
|
|
6912
|
+
"--list",
|
|
6913
|
+
listId,
|
|
6914
|
+
"--tags",
|
|
6915
|
+
task.tags.join(","),
|
|
6916
|
+
"--metadata-json",
|
|
6917
|
+
JSON.stringify(task.metadata)
|
|
6918
|
+
]);
|
|
6919
|
+
if (!result.ok) {
|
|
6920
|
+
return { action: "upsert-failed", check: task.check, fingerprint: task.fingerprint, error: result.stderr || result.error || result.stdout };
|
|
6921
|
+
}
|
|
6922
|
+
return { action: "upserted", check: task.check, fingerprint: task.fingerprint, task: JSON.parse(result.stdout || "{}") };
|
|
6923
|
+
});
|
|
6924
|
+
const routed = {
|
|
6925
|
+
ok: actions.every((action) => action.action !== "upsert-failed"),
|
|
6926
|
+
checks,
|
|
6927
|
+
checked: route.checked,
|
|
6928
|
+
findings: route.findings,
|
|
6929
|
+
routing: selection.cursor,
|
|
6930
|
+
actions
|
|
6931
|
+
};
|
|
6932
|
+
if (!opts.dryRun && routed.ok)
|
|
6933
|
+
writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
|
|
6934
|
+
if (isJson() || opts.json)
|
|
6935
|
+
console.log(JSON.stringify(routed, null, 2));
|
|
6936
|
+
else {
|
|
6937
|
+
console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${routed.findings} actions=${actions.length}`);
|
|
6938
|
+
for (const action of actions)
|
|
6939
|
+
console.log(`${action.action} ${action.fingerprint}`);
|
|
6940
|
+
}
|
|
6941
|
+
if (!routed.ok)
|
|
6942
|
+
process.exitCode = 1;
|
|
6943
|
+
} finally {
|
|
6944
|
+
store.close();
|
|
6945
|
+
}
|
|
6946
|
+
});
|
|
6644
6947
|
program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
|
|
6645
6948
|
program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
|
|
6646
6949
|
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.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
|
@@ -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,13 @@ 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. 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.
|
|
333
340
|
|
|
334
341
|
Archive loops when retiring old automation but preserving history:
|
|
335
342
|
|
package/package.json
CHANGED