@hasna/loops 0.3.25 → 0.3.26
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 +10 -0
- package/dist/cli/index.js +142 -18
- package/dist/daemon/index.js +1 -1
- package/docs/USAGE.md +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -318,6 +318,16 @@ mutating path: it upserts deduped Todos tasks for failed expectations and marks
|
|
|
318
318
|
them with `no_tmux_dispatch=true` metadata. Use `--dry-run --json` before
|
|
319
319
|
turning it into a production loop.
|
|
320
320
|
|
|
321
|
+
Add `--evidence-dir <dir>` to `health route-tasks` or `hygiene route-tasks`
|
|
322
|
+
when the deterministic loop should write a durable JSON heartbeat/report in
|
|
323
|
+
addition to loop stdout. Add `--auto-route` only for task lists that are
|
|
324
|
+
intentionally connected to task-created worker/verifier automation; it appends
|
|
325
|
+
`auto:route`, sets route metadata, and lets OpenTodos/OpenEvents trigger the
|
|
326
|
+
existing headless workflow path when the finding has a cwd or
|
|
327
|
+
`--route-project-path` is provided. Findings with no routeable working
|
|
328
|
+
directory stay as tasks and record an `auto_route_skipped_reason`. Without
|
|
329
|
+
`--auto-route`, the command only creates or updates deduped tasks.
|
|
330
|
+
|
|
321
331
|
`hygiene names` reports canonical `machine-*` or `repo-<name>-*` loop names and
|
|
322
332
|
renames only with `--apply`. Apply mode writes a SQLite backup under
|
|
323
333
|
`<LOOPS_DATA_DIR>/backups` before changing loop names. `hygiene duplicates`
|
package/dist/cli/index.js
CHANGED
|
@@ -2195,7 +2195,7 @@ class Store {
|
|
|
2195
2195
|
}
|
|
2196
2196
|
|
|
2197
2197
|
// src/cli/index.ts
|
|
2198
|
-
import { createHash as createHash2 } from "crypto";
|
|
2198
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
2199
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
2201
|
import { join as join3 } from "path";
|
|
@@ -5204,7 +5204,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5204
5204
|
// package.json
|
|
5205
5205
|
var package_default = {
|
|
5206
5206
|
name: "@hasna/loops",
|
|
5207
|
-
version: "0.3.
|
|
5207
|
+
version: "0.3.26",
|
|
5208
5208
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5209
5209
|
type: "module",
|
|
5210
5210
|
main: "dist/index.js",
|
|
@@ -5958,6 +5958,15 @@ function writeRouteCursor(key, lastFingerprint) {
|
|
|
5958
5958
|
cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
|
|
5959
5959
|
writeFileSync3(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
|
|
5960
5960
|
}
|
|
5961
|
+
function writeRouteEvidence(kind, value, evidenceDir) {
|
|
5962
|
+
if (!evidenceDir)
|
|
5963
|
+
return;
|
|
5964
|
+
mkdirSync5(evidenceDir, { recursive: true, mode: 448 });
|
|
5965
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
5966
|
+
const evidencePath = join3(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
5967
|
+
writeFileSync3(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
5968
|
+
return evidencePath;
|
|
5969
|
+
}
|
|
5961
5970
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
5962
5971
|
const total = items.length;
|
|
5963
5972
|
const boundedMax = Math.max(0, Math.floor(Number.isFinite(maxActions) ? maxActions : 0));
|
|
@@ -5984,6 +5993,77 @@ function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
|
5984
5993
|
}
|
|
5985
5994
|
};
|
|
5986
5995
|
}
|
|
5996
|
+
function taskAutoRoute(tags, base, opts) {
|
|
5997
|
+
if (!opts.autoRoute) {
|
|
5998
|
+
return {
|
|
5999
|
+
tags,
|
|
6000
|
+
metadata: {
|
|
6001
|
+
...base,
|
|
6002
|
+
route_enabled: false,
|
|
6003
|
+
project_path: null,
|
|
6004
|
+
working_dir: null,
|
|
6005
|
+
auto_route_requested: false,
|
|
6006
|
+
auto_route_enabled: false,
|
|
6007
|
+
automation: {
|
|
6008
|
+
allowed: false,
|
|
6009
|
+
source: opts.source,
|
|
6010
|
+
kind: "task-created-worker-verifier"
|
|
6011
|
+
}
|
|
6012
|
+
},
|
|
6013
|
+
autoRoute: { requested: false, enabled: false }
|
|
6014
|
+
};
|
|
6015
|
+
}
|
|
6016
|
+
const projectPath = typeof base.cwd === "string" && base.cwd.trim() || typeof opts.routeProjectPath === "string" && opts.routeProjectPath.trim() || undefined;
|
|
6017
|
+
if (!projectPath) {
|
|
6018
|
+
return {
|
|
6019
|
+
tags,
|
|
6020
|
+
metadata: {
|
|
6021
|
+
...base,
|
|
6022
|
+
route_enabled: false,
|
|
6023
|
+
project_path: null,
|
|
6024
|
+
working_dir: null,
|
|
6025
|
+
auto_route_requested: true,
|
|
6026
|
+
auto_route_enabled: false,
|
|
6027
|
+
auto_route_skipped_reason: "missing cwd or --route-project-path",
|
|
6028
|
+
automation: {
|
|
6029
|
+
allowed: false,
|
|
6030
|
+
source: opts.source,
|
|
6031
|
+
kind: "task-created-worker-verifier"
|
|
6032
|
+
}
|
|
6033
|
+
},
|
|
6034
|
+
autoRoute: {
|
|
6035
|
+
requested: true,
|
|
6036
|
+
enabled: false,
|
|
6037
|
+
skippedReason: "missing cwd or --route-project-path"
|
|
6038
|
+
}
|
|
6039
|
+
};
|
|
6040
|
+
}
|
|
6041
|
+
return {
|
|
6042
|
+
tags: [...new Set([...tags, "auto:route"])],
|
|
6043
|
+
metadata: {
|
|
6044
|
+
...base,
|
|
6045
|
+
route_enabled: true,
|
|
6046
|
+
project_path: projectPath,
|
|
6047
|
+
working_dir: projectPath,
|
|
6048
|
+
auto_route_requested: true,
|
|
6049
|
+
auto_route_enabled: true,
|
|
6050
|
+
automation: {
|
|
6051
|
+
allowed: true,
|
|
6052
|
+
source: opts.source,
|
|
6053
|
+
kind: "task-created-worker-verifier"
|
|
6054
|
+
}
|
|
6055
|
+
},
|
|
6056
|
+
autoRoute: { requested: true, enabled: true }
|
|
6057
|
+
};
|
|
6058
|
+
}
|
|
6059
|
+
function routeTaskWorkingDirArgs(routeTask) {
|
|
6060
|
+
const workingDir = routeTask.autoRoute.enabled && typeof routeTask.metadata.working_dir === "string" ? routeTask.metadata.working_dir : undefined;
|
|
6061
|
+
return workingDir ? ["--working-dir", workingDir] : [];
|
|
6062
|
+
}
|
|
6063
|
+
function routeCursorKey(kind, parts, opts) {
|
|
6064
|
+
const routeMode = opts.autoRoute ? ["auto-route", opts.routeProjectPath ?? "cwd"] : [];
|
|
6065
|
+
return `${kind}:${stableHash([...parts, ...routeMode])}`;
|
|
6066
|
+
}
|
|
5987
6067
|
function eventData(event) {
|
|
5988
6068
|
const data = event.data;
|
|
5989
6069
|
if (data && typeof data === "object" && !Array.isArray(data))
|
|
@@ -6790,26 +6870,43 @@ var health = program.command("health").description("summarize loop health and la
|
|
|
6790
6870
|
store.close();
|
|
6791
6871
|
}
|
|
6792
6872
|
});
|
|
6793
|
-
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) => {
|
|
6873
|
+
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("--auto-route", "opt routed tasks into task-created headless worker/verifier automation").option("--route-project-path <path>", "fallback project path for --auto-route when the failed loop has no cwd").option("--evidence-dir <path>", "write the route result JSON to this directory").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
|
|
6794
6874
|
const store = new Store;
|
|
6795
6875
|
try {
|
|
6796
6876
|
const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
|
|
6797
6877
|
const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask);
|
|
6798
|
-
const selection = selectRouteItems(failures, Number(opts.maxActions),
|
|
6878
|
+
const selection = selectRouteItems(failures, Number(opts.maxActions), routeCursorKey("health", [opts.project, opts.taskList, opts.limit, Boolean(opts.includeInactive)], {
|
|
6879
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
6880
|
+
routeProjectPath: opts.routeProjectPath
|
|
6881
|
+
}), (expectation) => expectation.recommendedTask.dedupeKey);
|
|
6799
6882
|
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.");
|
|
6800
6883
|
const actions = selection.selected.map((expectation) => {
|
|
6801
6884
|
const task = expectation.recommendedTask;
|
|
6802
|
-
const
|
|
6885
|
+
const routeTask = taskAutoRoute(task.tags, {
|
|
6803
6886
|
source: "openloops.health.route-tasks",
|
|
6804
6887
|
loop_id: expectation.loop.id,
|
|
6805
6888
|
loop_name: expectation.loop.name,
|
|
6806
6889
|
run_id: expectation.latestRun?.id,
|
|
6807
6890
|
classification: expectation.failure?.classification,
|
|
6808
6891
|
fingerprint: task.dedupeKey,
|
|
6892
|
+
cwd: expectation.route.cwd,
|
|
6893
|
+
provider: expectation.route.provider,
|
|
6809
6894
|
no_tmux_dispatch: true
|
|
6810
|
-
}
|
|
6895
|
+
}, {
|
|
6896
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
6897
|
+
routeProjectPath: opts.routeProjectPath,
|
|
6898
|
+
source: "openloops.health.route-tasks"
|
|
6899
|
+
});
|
|
6811
6900
|
if (opts.dryRun) {
|
|
6812
|
-
return {
|
|
6901
|
+
return {
|
|
6902
|
+
action: "would-upsert",
|
|
6903
|
+
title: task.title,
|
|
6904
|
+
fingerprint: task.dedupeKey,
|
|
6905
|
+
priority: task.priority,
|
|
6906
|
+
tags: routeTask.tags,
|
|
6907
|
+
metadata: routeTask.metadata,
|
|
6908
|
+
autoRoute: routeTask.autoRoute
|
|
6909
|
+
};
|
|
6813
6910
|
}
|
|
6814
6911
|
const result = runLocalCommand("todos", [
|
|
6815
6912
|
"--project",
|
|
@@ -6830,9 +6927,10 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
6830
6927
|
"--list",
|
|
6831
6928
|
listId,
|
|
6832
6929
|
"--tags",
|
|
6833
|
-
|
|
6930
|
+
routeTask.tags.join(","),
|
|
6931
|
+
...routeTaskWorkingDirArgs(routeTask),
|
|
6834
6932
|
"--metadata-json",
|
|
6835
|
-
JSON.stringify(metadata)
|
|
6933
|
+
JSON.stringify(routeTask.metadata)
|
|
6836
6934
|
]);
|
|
6837
6935
|
if (!result.ok) {
|
|
6838
6936
|
return { action: "upsert-failed", fingerprint: task.dedupeKey, error: result.stderr || result.error || result.stdout };
|
|
@@ -6846,12 +6944,16 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
6846
6944
|
routing: selection.cursor,
|
|
6847
6945
|
actions
|
|
6848
6946
|
};
|
|
6947
|
+
const evidencePath = writeRouteEvidence("health-route-tasks", routed, opts.evidenceDir);
|
|
6948
|
+
const output = evidencePath ? { ...routed, evidencePath } : routed;
|
|
6849
6949
|
if (!opts.dryRun && routed.ok)
|
|
6850
6950
|
writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
|
|
6851
6951
|
if (isJson() || opts.json)
|
|
6852
|
-
console.log(JSON.stringify(
|
|
6952
|
+
console.log(JSON.stringify(output, null, 2));
|
|
6853
6953
|
else {
|
|
6854
|
-
console.log(`health_route_tasks inspected=${
|
|
6954
|
+
console.log(`health_route_tasks inspected=${output.inspected} failures=${output.failures} actions=${actions.length}`);
|
|
6955
|
+
if (evidencePath)
|
|
6956
|
+
console.log(`evidence=${evidencePath}`);
|
|
6855
6957
|
for (const action of actions)
|
|
6856
6958
|
console.log(`${action.action} ${action.fingerprint}`);
|
|
6857
6959
|
}
|
|
@@ -7074,7 +7176,7 @@ hygiene.command("scripts").description("inventory loops still backed by local ~/
|
|
|
7074
7176
|
store.close();
|
|
7075
7177
|
}
|
|
7076
7178
|
});
|
|
7077
|
-
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) => {
|
|
7179
|
+
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("--auto-route", "opt routed tasks into task-created headless worker/verifier automation").option("--route-project-path <path>", "fallback project path for --auto-route when the hygiene finding has no cwd").option("--evidence-dir <path>", "write the route result JSON to this directory").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
|
|
7078
7180
|
const store = new Store;
|
|
7079
7181
|
try {
|
|
7080
7182
|
const checks = parseHygieneChecks(opts.checks);
|
|
@@ -7084,11 +7186,28 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
7084
7186
|
limit: Number(opts.limit),
|
|
7085
7187
|
scriptsDir: opts.scriptsDir
|
|
7086
7188
|
});
|
|
7087
|
-
const selection = selectRouteItems(route.tasks, Number(opts.maxActions),
|
|
7189
|
+
const selection = selectRouteItems(route.tasks, Number(opts.maxActions), routeCursorKey("hygiene", [opts.project, opts.taskList, checks, opts.limit, Boolean(opts.includeInactive), opts.scriptsDir ?? ""], {
|
|
7190
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
7191
|
+
routeProjectPath: opts.routeProjectPath
|
|
7192
|
+
}), (task) => task.fingerprint);
|
|
7088
7193
|
const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "OpenLoops Hygiene", "Deduped OpenLoops hygiene findings routed by loops hygiene route-tasks.");
|
|
7089
7194
|
const actions = selection.selected.map((task) => {
|
|
7195
|
+
const routeTask = taskAutoRoute(task.tags, task.metadata, {
|
|
7196
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
7197
|
+
routeProjectPath: opts.routeProjectPath,
|
|
7198
|
+
source: "openloops.hygiene.route-tasks"
|
|
7199
|
+
});
|
|
7090
7200
|
if (opts.dryRun) {
|
|
7091
|
-
return {
|
|
7201
|
+
return {
|
|
7202
|
+
action: "would-upsert",
|
|
7203
|
+
check: task.check,
|
|
7204
|
+
title: task.title,
|
|
7205
|
+
fingerprint: task.fingerprint,
|
|
7206
|
+
priority: task.priority,
|
|
7207
|
+
tags: routeTask.tags,
|
|
7208
|
+
metadata: routeTask.metadata,
|
|
7209
|
+
autoRoute: routeTask.autoRoute
|
|
7210
|
+
};
|
|
7092
7211
|
}
|
|
7093
7212
|
const result = runLocalCommand("todos", [
|
|
7094
7213
|
"--project",
|
|
@@ -7109,9 +7228,10 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
7109
7228
|
"--list",
|
|
7110
7229
|
listId,
|
|
7111
7230
|
"--tags",
|
|
7112
|
-
|
|
7231
|
+
routeTask.tags.join(","),
|
|
7232
|
+
...routeTaskWorkingDirArgs(routeTask),
|
|
7113
7233
|
"--metadata-json",
|
|
7114
|
-
JSON.stringify(
|
|
7234
|
+
JSON.stringify(routeTask.metadata)
|
|
7115
7235
|
]);
|
|
7116
7236
|
if (!result.ok) {
|
|
7117
7237
|
return { action: "upsert-failed", check: task.check, fingerprint: task.fingerprint, error: result.stderr || result.error || result.stdout };
|
|
@@ -7126,12 +7246,16 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
7126
7246
|
routing: selection.cursor,
|
|
7127
7247
|
actions
|
|
7128
7248
|
};
|
|
7249
|
+
const evidencePath = writeRouteEvidence("hygiene-route-tasks", routed, opts.evidenceDir);
|
|
7250
|
+
const output = evidencePath ? { ...routed, evidencePath } : routed;
|
|
7129
7251
|
if (!opts.dryRun && routed.ok)
|
|
7130
7252
|
writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
|
|
7131
7253
|
if (isJson() || opts.json)
|
|
7132
|
-
console.log(JSON.stringify(
|
|
7254
|
+
console.log(JSON.stringify(output, null, 2));
|
|
7133
7255
|
else {
|
|
7134
|
-
console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${
|
|
7256
|
+
console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${output.findings} actions=${actions.length}`);
|
|
7257
|
+
if (evidencePath)
|
|
7258
|
+
console.log(`evidence=${evidencePath}`);
|
|
7135
7259
|
for (const action of actions)
|
|
7136
7260
|
console.log(`${action.action} ${action.fingerprint}`);
|
|
7137
7261
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -4525,7 +4525,7 @@ function enableStartup(result) {
|
|
|
4525
4525
|
// package.json
|
|
4526
4526
|
var package_default = {
|
|
4527
4527
|
name: "@hasna/loops",
|
|
4528
|
-
version: "0.3.
|
|
4528
|
+
version: "0.3.26",
|
|
4529
4529
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4530
4530
|
type: "module",
|
|
4531
4531
|
main: "dist/index.js",
|
package/docs/USAGE.md
CHANGED
|
@@ -338,6 +338,14 @@ Use `--dry-run --json` first when testing a new automation path. Routed tasks
|
|
|
338
338
|
include the stable failure fingerprint, classification, loop id/name, and
|
|
339
339
|
`no_tmux_dispatch=true` metadata.
|
|
340
340
|
|
|
341
|
+
Use `--evidence-dir <dir>` when a deterministic loop needs a compact JSON
|
|
342
|
+
heartbeat/report on disk. Use `--auto-route` only on task lists that should feed
|
|
343
|
+
the task-created headless worker/verifier workflow; it adds the `auto:route`
|
|
344
|
+
tag and route metadata when the finding has a cwd or `--route-project-path` is
|
|
345
|
+
provided. Findings with no routeable working directory remain plain tasks and
|
|
346
|
+
record an `auto_route_skipped_reason`. Without `--auto-route`, route commands
|
|
347
|
+
only upsert deduped tasks and do not launch agents.
|
|
348
|
+
|
|
341
349
|
Failure classifications are: `rate_limit`, `auth`, `model_not_found`,
|
|
342
350
|
`context_length`, `schema_response_format`, `node_init`, `timeout`, `sigsegv`,
|
|
343
351
|
`skipped_previous_active`, and `unknown`.
|
package/package.json
CHANGED