@hasna/loops 0.3.49 → 0.3.51
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 +76 -0
- package/dist/cli/index.js +452 -28
- package/dist/daemon/index.js +1 -1
- package/dist/index.js +373 -10
- package/dist/lib/templates.d.ts +24 -4
- package/dist/types.d.ts +5 -0
- package/docs/USAGE.md +75 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -251,6 +251,67 @@ loops templates render deterministic-check-create-task \
|
|
|
251
251
|
--var checkCommand='your deterministic check and todos upsert command'
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
+
Custom reusable workflow templates live under the OpenLoops app data directory:
|
|
255
|
+
`~/.hasna/loops/templates` by default, or `$LOOPS_DATA_DIR/templates` when
|
|
256
|
+
`LOOPS_DATA_DIR` is set. Store templates as declarative JSON files; listing,
|
|
257
|
+
showing, and rendering templates never executes workflow steps or mutates the
|
|
258
|
+
registry.
|
|
259
|
+
|
|
260
|
+
```json
|
|
261
|
+
{
|
|
262
|
+
"id": "custom-report",
|
|
263
|
+
"name": "Custom Report",
|
|
264
|
+
"description": "Run a custom report workflow from the local template registry.",
|
|
265
|
+
"kind": "workflow",
|
|
266
|
+
"variables": [
|
|
267
|
+
{ "name": "objective", "required": true, "description": "Report objective." },
|
|
268
|
+
{ "name": "projectPath", "required": true, "description": "Working directory." },
|
|
269
|
+
{ "name": "timeoutMs", "default": "300000", "type": "number" }
|
|
270
|
+
],
|
|
271
|
+
"workflow": {
|
|
272
|
+
"name": "custom-report-${objective}",
|
|
273
|
+
"steps": [
|
|
274
|
+
{
|
|
275
|
+
"id": "worker",
|
|
276
|
+
"target": {
|
|
277
|
+
"type": "agent",
|
|
278
|
+
"provider": "codewith",
|
|
279
|
+
"prompt": "/goal ${objective}\nProduce the requested report only.",
|
|
280
|
+
"cwd": "${projectPath}",
|
|
281
|
+
"configIsolation": "safe",
|
|
282
|
+
"permissionMode": "bypass",
|
|
283
|
+
"sandbox": "workspace-write",
|
|
284
|
+
"timeoutMs": "${timeoutMs}"
|
|
285
|
+
},
|
|
286
|
+
"timeoutMs": "${timeoutMs}"
|
|
287
|
+
}
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
loops templates validate ./custom-report.json
|
|
295
|
+
loops templates import ./custom-report.json
|
|
296
|
+
loops templates list --source custom
|
|
297
|
+
loops templates show custom-report
|
|
298
|
+
loops templates render custom-report \
|
|
299
|
+
--var objective="Check docs drift" \
|
|
300
|
+
--var projectPath=/path/to/repo
|
|
301
|
+
loops templates create-workflow custom-report \
|
|
302
|
+
--var objective="Check docs drift" \
|
|
303
|
+
--var projectPath=/path/to/repo
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Use `--source builtin`, `--source custom`, or `--source all` on
|
|
307
|
+
`list`, `show`, `render`, and `create-workflow` when automation needs an
|
|
308
|
+
explicit source. Custom template ids and names cannot override built-ins.
|
|
309
|
+
Custom templates fail closed for `danger-full-access`, dangerous passthrough
|
|
310
|
+
arguments, and implicit Codewith/Codex full-access defaults. If a custom
|
|
311
|
+
Codewith/Codex template uses `permissionMode: "bypass"`, it must also set
|
|
312
|
+
`sandbox` to `workspace-write` or `read-only`. Use built-in templates with
|
|
313
|
+
explicit break-glass handling for emergency workflows that need full access.
|
|
314
|
+
|
|
254
315
|
For event-driven task automation, `loops events handle todos-task` reads a
|
|
255
316
|
Hasna event envelope from stdin or `HASNA_EVENT_JSON`, records a
|
|
256
317
|
`WorkflowInvocation`, upserts an admission work item, and admits that work item
|
|
@@ -421,6 +482,7 @@ loops runs <id-or-name>
|
|
|
421
482
|
loops pause <id-or-name>
|
|
422
483
|
loops resume <id-or-name>
|
|
423
484
|
loops stop <id-or-name>
|
|
485
|
+
loops rename <id-or-name> <new-name>
|
|
424
486
|
loops archive <id-or-name>
|
|
425
487
|
loops unarchive <id-or-name>
|
|
426
488
|
loops remove <id-or-name>
|
|
@@ -468,6 +530,20 @@ package-managed cursor under `<LOOPS_DATA_DIR>/route-cursors.json` so bounded
|
|
|
468
530
|
`--max-actions` runs advance through all findings over repeated scheduled runs
|
|
469
531
|
instead of reprocessing only the first batch.
|
|
470
532
|
|
|
533
|
+
Use `loops rename` for deliberate operator naming changes that should not be
|
|
534
|
+
encoded as automatic hygiene policy:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
loops rename machine-todos-drain-oss-repos-strict-5m machine-todos-drain-oss-repos
|
|
538
|
+
loops --json rename <loop-id> machine-ops-loop-health-route-tasks
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
`rename` preserves the loop id, schedule, run history, archive state, and target
|
|
542
|
+
configuration. It rejects empty or duplicate names and writes a SQLite backup
|
|
543
|
+
under `<LOOPS_DATA_DIR>/backups` before mutating the loop row. Keep cadence in
|
|
544
|
+
schedule metadata and operator tables rather than in the loop name when the name
|
|
545
|
+
is meant for humans.
|
|
546
|
+
|
|
471
547
|
Archive loops when retiring old automation but preserving history:
|
|
472
548
|
|
|
473
549
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -2855,7 +2855,7 @@ class Store {
|
|
|
2855
2855
|
|
|
2856
2856
|
// src/cli/index.ts
|
|
2857
2857
|
import { createHash as createHash3, randomUUID } from "crypto";
|
|
2858
|
-
import { closeSync, existsSync as existsSync5, mkdirSync as
|
|
2858
|
+
import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync3, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
2859
2859
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
2860
2860
|
import { join as join6, resolve as resolve2 } from "path";
|
|
2861
2861
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -5908,7 +5908,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5908
5908
|
// package.json
|
|
5909
5909
|
var package_default = {
|
|
5910
5910
|
name: "@hasna/loops",
|
|
5911
|
-
version: "0.3.
|
|
5911
|
+
version: "0.3.51",
|
|
5912
5912
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5913
5913
|
type: "module",
|
|
5914
5914
|
main: "dist/index.js",
|
|
@@ -5998,7 +5998,7 @@ function packageVersion() {
|
|
|
5998
5998
|
|
|
5999
5999
|
// src/lib/templates.ts
|
|
6000
6000
|
import { execFileSync } from "child_process";
|
|
6001
|
-
import { existsSync as existsSync4 } from "fs";
|
|
6001
|
+
import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
6002
6002
|
import { homedir as homedir3 } from "os";
|
|
6003
6003
|
import { basename as basename3, isAbsolute, join as join5, relative, resolve } from "path";
|
|
6004
6004
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
@@ -6199,6 +6199,16 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6199
6199
|
]
|
|
6200
6200
|
}
|
|
6201
6201
|
];
|
|
6202
|
+
var CUSTOM_TEMPLATE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
|
6203
|
+
var CUSTOM_TEMPLATE_VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
6204
|
+
var CUSTOM_TEMPLATE_VARIABLE_TYPES = new Set(["string", "number", "boolean", "json", "string[]"]);
|
|
6205
|
+
var CUSTOM_TEMPLATE_PLACEHOLDER = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
|
|
6206
|
+
var CUSTOM_TEMPLATE_EXACT_PLACEHOLDER = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/;
|
|
6207
|
+
var CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS = [
|
|
6208
|
+
"danger-full-access",
|
|
6209
|
+
"dangerously-bypass",
|
|
6210
|
+
"dangerously-skip"
|
|
6211
|
+
];
|
|
6202
6212
|
function compactJson(value) {
|
|
6203
6213
|
return JSON.stringify(value);
|
|
6204
6214
|
}
|
|
@@ -6476,11 +6486,350 @@ function workflowStepsWithWorktree(plan, steps) {
|
|
|
6476
6486
|
...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
|
|
6477
6487
|
];
|
|
6478
6488
|
}
|
|
6479
|
-
function
|
|
6480
|
-
|
|
6489
|
+
function assertRecord(value, label) {
|
|
6490
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
6491
|
+
throw new Error(`${label} must be an object`);
|
|
6492
|
+
}
|
|
6493
|
+
function assertTemplateString(value, label) {
|
|
6494
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
6495
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
6496
|
+
return value.trim();
|
|
6497
|
+
}
|
|
6498
|
+
function assertTemplateKind(value, label) {
|
|
6499
|
+
const kind = assertTemplateString(value, label);
|
|
6500
|
+
if (kind !== "workflow")
|
|
6501
|
+
throw new Error(`${label} must be workflow; custom loop templates are not supported yet`);
|
|
6502
|
+
return kind;
|
|
6503
|
+
}
|
|
6504
|
+
function customLoopTemplatesDir() {
|
|
6505
|
+
return join5(dataDir(), "templates");
|
|
6506
|
+
}
|
|
6507
|
+
function ensureCustomLoopTemplatesDir() {
|
|
6508
|
+
const dir = customLoopTemplatesDir();
|
|
6509
|
+
mkdirSync6(dir, { recursive: true, mode: 448 });
|
|
6510
|
+
return dir;
|
|
6511
|
+
}
|
|
6512
|
+
function loopTemplatesDir() {
|
|
6513
|
+
return customLoopTemplatesDir();
|
|
6514
|
+
}
|
|
6515
|
+
function builtinLoopTemplates() {
|
|
6516
|
+
return TEMPLATE_SUMMARIES.map((template) => ({ ...structuredClone(template), source: "builtin" }));
|
|
6517
|
+
}
|
|
6518
|
+
function getBuiltinLoopTemplate(id) {
|
|
6519
|
+
return builtinLoopTemplates().find((template) => template.id === id || template.name === id);
|
|
6520
|
+
}
|
|
6521
|
+
function builtinTemplateKeys() {
|
|
6522
|
+
const keys = new Set;
|
|
6523
|
+
for (const template of TEMPLATE_SUMMARIES) {
|
|
6524
|
+
keys.add(template.id);
|
|
6525
|
+
keys.add(template.name);
|
|
6526
|
+
}
|
|
6527
|
+
return keys;
|
|
6528
|
+
}
|
|
6529
|
+
function validateCustomTemplateId(id, label) {
|
|
6530
|
+
if (!CUSTOM_TEMPLATE_ID_PATTERN.test(id)) {
|
|
6531
|
+
throw new Error(`${label} must match ${CUSTOM_TEMPLATE_ID_PATTERN.source}`);
|
|
6532
|
+
}
|
|
6533
|
+
}
|
|
6534
|
+
function optionalTemplateBoolean(value, label) {
|
|
6535
|
+
if (value === undefined)
|
|
6536
|
+
return;
|
|
6537
|
+
if (typeof value !== "boolean")
|
|
6538
|
+
throw new Error(`${label} must be a boolean`);
|
|
6539
|
+
return value;
|
|
6540
|
+
}
|
|
6541
|
+
function validateCustomTemplateVariables(value, label) {
|
|
6542
|
+
if (value === undefined)
|
|
6543
|
+
return [];
|
|
6544
|
+
if (!Array.isArray(value))
|
|
6545
|
+
throw new Error(`${label} must be an array`);
|
|
6546
|
+
const seen = new Set;
|
|
6547
|
+
return value.map((entry, index) => {
|
|
6548
|
+
const entryLabel = `${label}[${index}]`;
|
|
6549
|
+
assertRecord(entry, entryLabel);
|
|
6550
|
+
const name = assertTemplateString(entry.name, `${entryLabel}.name`);
|
|
6551
|
+
if (!CUSTOM_TEMPLATE_VARIABLE_PATTERN.test(name)) {
|
|
6552
|
+
throw new Error(`${entryLabel}.name must match ${CUSTOM_TEMPLATE_VARIABLE_PATTERN.source}`);
|
|
6553
|
+
}
|
|
6554
|
+
if (seen.has(name))
|
|
6555
|
+
throw new Error(`duplicate custom template variable: ${name}`);
|
|
6556
|
+
seen.add(name);
|
|
6557
|
+
const description = entry.description === undefined ? undefined : assertTemplateString(entry.description, `${entryLabel}.description`);
|
|
6558
|
+
const defaultValue = entry.default === undefined ? undefined : assertTemplateString(entry.default, `${entryLabel}.default`);
|
|
6559
|
+
const type = entry.type === undefined ? undefined : assertTemplateString(entry.type, `${entryLabel}.type`);
|
|
6560
|
+
if (type && !CUSTOM_TEMPLATE_VARIABLE_TYPES.has(type)) {
|
|
6561
|
+
throw new Error(`${entryLabel}.type must be one of ${[...CUSTOM_TEMPLATE_VARIABLE_TYPES].join(", ")}`);
|
|
6562
|
+
}
|
|
6563
|
+
if (defaultValue && CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS.some((pattern) => defaultValue.includes(pattern))) {
|
|
6564
|
+
throw new Error(`${entryLabel}.default cannot contain dangerous sandbox or bypass flags in a custom template`);
|
|
6565
|
+
}
|
|
6566
|
+
return {
|
|
6567
|
+
name,
|
|
6568
|
+
description,
|
|
6569
|
+
required: optionalTemplateBoolean(entry.required, `${entryLabel}.required`),
|
|
6570
|
+
default: defaultValue,
|
|
6571
|
+
type
|
|
6572
|
+
};
|
|
6573
|
+
});
|
|
6574
|
+
}
|
|
6575
|
+
function hasDangerousArg(value) {
|
|
6576
|
+
return CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS.some((pattern) => value.includes(pattern));
|
|
6577
|
+
}
|
|
6578
|
+
function assertNoDangerousCustomTemplateScalars(value, label) {
|
|
6579
|
+
if (typeof value === "string") {
|
|
6580
|
+
if (hasDangerousArg(value)) {
|
|
6581
|
+
throw new Error(`${label} contains a dangerous sandbox or bypass flag; custom templates must not request danger-full-access`);
|
|
6582
|
+
}
|
|
6583
|
+
return;
|
|
6584
|
+
}
|
|
6585
|
+
if (!value || typeof value !== "object")
|
|
6586
|
+
return;
|
|
6587
|
+
if (Array.isArray(value)) {
|
|
6588
|
+
value.forEach((entry, index) => assertNoDangerousCustomTemplateScalars(entry, `${label}[${index}]`));
|
|
6589
|
+
return;
|
|
6590
|
+
}
|
|
6591
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
6592
|
+
assertNoDangerousCustomTemplateScalars(entry, `${label}.${key}`);
|
|
6593
|
+
}
|
|
6481
6594
|
}
|
|
6482
|
-
function
|
|
6483
|
-
|
|
6595
|
+
function assertNoImplicitDangerFullAccess(value, label) {
|
|
6596
|
+
if (!value || typeof value !== "object")
|
|
6597
|
+
return;
|
|
6598
|
+
if (Array.isArray(value)) {
|
|
6599
|
+
value.forEach((entry, index) => assertNoImplicitDangerFullAccess(entry, `${label}[${index}]`));
|
|
6600
|
+
return;
|
|
6601
|
+
}
|
|
6602
|
+
const object = value;
|
|
6603
|
+
if (object.type === "agent" && (object.provider === "codewith" || object.provider === "codex") && object.permissionMode === "bypass" && object.sandbox === undefined) {
|
|
6604
|
+
throw new Error(`${label} uses permissionMode=bypass for ${object.provider} without an explicit sandbox; set sandbox=workspace-write or read-only`);
|
|
6605
|
+
}
|
|
6606
|
+
for (const [key, entry] of Object.entries(object)) {
|
|
6607
|
+
assertNoImplicitDangerFullAccess(entry, `${label}.${key}`);
|
|
6608
|
+
}
|
|
6609
|
+
}
|
|
6610
|
+
function assertCustomTemplateSafety(value, label) {
|
|
6611
|
+
assertNoDangerousCustomTemplateScalars(value, label);
|
|
6612
|
+
assertNoImplicitDangerFullAccess(value, label);
|
|
6613
|
+
}
|
|
6614
|
+
function customTemplateDefinitionFromJson(value, sourcePath) {
|
|
6615
|
+
assertRecord(value, sourcePath);
|
|
6616
|
+
const id = assertTemplateString(value.id, `${sourcePath}.id`);
|
|
6617
|
+
validateCustomTemplateId(id, `${sourcePath}.id`);
|
|
6618
|
+
const name = assertTemplateString(value.name, `${sourcePath}.name`);
|
|
6619
|
+
const description = assertTemplateString(value.description, `${sourcePath}.description`);
|
|
6620
|
+
const kind = assertTemplateKind(value.kind ?? "workflow", `${sourcePath}.kind`);
|
|
6621
|
+
const variables = validateCustomTemplateVariables(value.variables, `${sourcePath}.variables`);
|
|
6622
|
+
if (value.workflow === undefined)
|
|
6623
|
+
throw new Error(`${sourcePath}.workflow is required`);
|
|
6624
|
+
assertRecord(value.workflow, `${sourcePath}.workflow`);
|
|
6625
|
+
assertCustomTemplateSafety(value.workflow, `${sourcePath}.workflow`);
|
|
6626
|
+
return { id, name, description, kind, variables, workflow: value.workflow };
|
|
6627
|
+
}
|
|
6628
|
+
function customTemplateSummary(definition, sourcePath) {
|
|
6629
|
+
return {
|
|
6630
|
+
id: definition.id,
|
|
6631
|
+
name: definition.name,
|
|
6632
|
+
description: definition.description,
|
|
6633
|
+
kind: definition.kind,
|
|
6634
|
+
variables: structuredClone(definition.variables),
|
|
6635
|
+
source: "custom",
|
|
6636
|
+
sourcePath
|
|
6637
|
+
};
|
|
6638
|
+
}
|
|
6639
|
+
function readCustomTemplateFile(file) {
|
|
6640
|
+
let parsed;
|
|
6641
|
+
try {
|
|
6642
|
+
parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
6643
|
+
} catch (error) {
|
|
6644
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6645
|
+
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
6646
|
+
}
|
|
6647
|
+
const definition = customTemplateDefinitionFromJson(parsed, file);
|
|
6648
|
+
return { definition, summary: customTemplateSummary(definition, file), path: file };
|
|
6649
|
+
}
|
|
6650
|
+
function assertNoTemplateCollisions(entries) {
|
|
6651
|
+
const builtinKeys = builtinTemplateKeys();
|
|
6652
|
+
const seen = new Map;
|
|
6653
|
+
for (const entry of entries) {
|
|
6654
|
+
for (const key of [entry.definition.id, entry.definition.name]) {
|
|
6655
|
+
if (builtinKeys.has(key)) {
|
|
6656
|
+
throw new Error(`custom template ${entry.definition.id} collides with built-in template key ${key}; choose a different id or name`);
|
|
6657
|
+
}
|
|
6658
|
+
const existing = seen.get(key);
|
|
6659
|
+
if (existing) {
|
|
6660
|
+
throw new Error(`custom template ${entry.definition.id} collides with ${existing} on key ${key}`);
|
|
6661
|
+
}
|
|
6662
|
+
seen.set(key, entry.definition.id);
|
|
6663
|
+
}
|
|
6664
|
+
}
|
|
6665
|
+
}
|
|
6666
|
+
function loadCustomLoopTemplatesRaw() {
|
|
6667
|
+
const dir = customLoopTemplatesDir();
|
|
6668
|
+
if (!existsSync4(dir))
|
|
6669
|
+
return [];
|
|
6670
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
6671
|
+
const file = join5(dir, entry.name);
|
|
6672
|
+
if (entry.isSymbolicLink())
|
|
6673
|
+
throw new Error(`refusing symlinked custom template file: ${file}`);
|
|
6674
|
+
if (!entry.isFile())
|
|
6675
|
+
throw new Error(`custom template registry entry is not a regular file: ${file}`);
|
|
6676
|
+
return readCustomTemplateFile(file);
|
|
6677
|
+
});
|
|
6678
|
+
}
|
|
6679
|
+
function loadCustomLoopTemplates() {
|
|
6680
|
+
const entries = loadCustomLoopTemplatesRaw();
|
|
6681
|
+
assertNoTemplateCollisions(entries);
|
|
6682
|
+
return entries;
|
|
6683
|
+
}
|
|
6684
|
+
function getCustomLoopTemplate(id) {
|
|
6685
|
+
return loadCustomLoopTemplates().find((template) => template.definition.id === id || template.definition.name === id);
|
|
6686
|
+
}
|
|
6687
|
+
function coerceCustomTemplateValue(raw, type, label) {
|
|
6688
|
+
const normalizedType = type ?? "string";
|
|
6689
|
+
if (normalizedType === "string")
|
|
6690
|
+
return String(raw);
|
|
6691
|
+
if (normalizedType === "number") {
|
|
6692
|
+
const value = typeof raw === "number" ? raw : Number(String(raw));
|
|
6693
|
+
if (!Number.isFinite(value))
|
|
6694
|
+
throw new Error(`${label} must be a finite number`);
|
|
6695
|
+
return value;
|
|
6696
|
+
}
|
|
6697
|
+
if (normalizedType === "boolean") {
|
|
6698
|
+
if (typeof raw === "boolean")
|
|
6699
|
+
return raw;
|
|
6700
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
6701
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
6702
|
+
return true;
|
|
6703
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
6704
|
+
return false;
|
|
6705
|
+
throw new Error(`${label} must be a boolean`);
|
|
6706
|
+
}
|
|
6707
|
+
if (normalizedType === "json") {
|
|
6708
|
+
if (typeof raw !== "string")
|
|
6709
|
+
return raw;
|
|
6710
|
+
try {
|
|
6711
|
+
return JSON.parse(raw);
|
|
6712
|
+
} catch (error) {
|
|
6713
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6714
|
+
throw new Error(`${label} must be valid JSON: ${message}`);
|
|
6715
|
+
}
|
|
6716
|
+
}
|
|
6717
|
+
if (normalizedType === "string[]") {
|
|
6718
|
+
if (Array.isArray(raw))
|
|
6719
|
+
return raw.map((entry) => String(entry));
|
|
6720
|
+
return String(raw).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
6721
|
+
}
|
|
6722
|
+
return String(raw);
|
|
6723
|
+
}
|
|
6724
|
+
function customTemplateValues(definition, values) {
|
|
6725
|
+
const variablesByName = new Map(definition.variables.map((variable) => [variable.name, variable]));
|
|
6726
|
+
for (const [name, value] of Object.entries(values)) {
|
|
6727
|
+
if (value !== undefined && !variablesByName.has(name)) {
|
|
6728
|
+
throw new Error(`unknown variable for custom template ${definition.id}: ${name}`);
|
|
6729
|
+
}
|
|
6730
|
+
}
|
|
6731
|
+
const rendered = {};
|
|
6732
|
+
for (const variable of definition.variables) {
|
|
6733
|
+
const raw = values[variable.name] ?? variable.default;
|
|
6734
|
+
if (raw === undefined || raw === "") {
|
|
6735
|
+
if (variable.required)
|
|
6736
|
+
throw new Error(`${variable.name} is required`);
|
|
6737
|
+
continue;
|
|
6738
|
+
}
|
|
6739
|
+
rendered[variable.name] = coerceCustomTemplateValue(raw, variable.type, variable.name);
|
|
6740
|
+
}
|
|
6741
|
+
return rendered;
|
|
6742
|
+
}
|
|
6743
|
+
function customTemplateValueForPlaceholder(values, name, templateId) {
|
|
6744
|
+
if (!(name in values))
|
|
6745
|
+
throw new Error(`custom template ${templateId} requires variable ${name}`);
|
|
6746
|
+
return values[name];
|
|
6747
|
+
}
|
|
6748
|
+
function stringifyCustomTemplateValue(value, name) {
|
|
6749
|
+
if (typeof value === "string")
|
|
6750
|
+
return value;
|
|
6751
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
6752
|
+
return String(value);
|
|
6753
|
+
if (value === null)
|
|
6754
|
+
return "null";
|
|
6755
|
+
if (Array.isArray(value) || typeof value === "object")
|
|
6756
|
+
return JSON.stringify(value);
|
|
6757
|
+
throw new Error(`custom template variable ${name} cannot be rendered as a string`);
|
|
6758
|
+
}
|
|
6759
|
+
function renderCustomTemplateNode(value, values, templateId) {
|
|
6760
|
+
if (typeof value === "string") {
|
|
6761
|
+
const exact = CUSTOM_TEMPLATE_EXACT_PLACEHOLDER.exec(value);
|
|
6762
|
+
if (exact)
|
|
6763
|
+
return customTemplateValueForPlaceholder(values, exact[1], templateId);
|
|
6764
|
+
return value.replace(CUSTOM_TEMPLATE_PLACEHOLDER, (_match, name) => stringifyCustomTemplateValue(customTemplateValueForPlaceholder(values, name, templateId), name));
|
|
6765
|
+
}
|
|
6766
|
+
if (Array.isArray(value))
|
|
6767
|
+
return value.map((entry) => renderCustomTemplateNode(entry, values, templateId));
|
|
6768
|
+
if (!value || typeof value !== "object")
|
|
6769
|
+
return value;
|
|
6770
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderCustomTemplateNode(entry, values, templateId)]));
|
|
6771
|
+
}
|
|
6772
|
+
function renderCustomLoopTemplate(entry, values) {
|
|
6773
|
+
const renderedValues = customTemplateValues(entry.definition, values);
|
|
6774
|
+
const rendered = renderCustomTemplateNode(entry.definition.workflow, renderedValues, entry.definition.id);
|
|
6775
|
+
assertCustomTemplateSafety(rendered, `custom template ${entry.definition.id}.workflow`);
|
|
6776
|
+
const workflow = workflowBodyFromJson(rendered);
|
|
6777
|
+
assertCustomTemplateSafety(workflow, `custom template ${entry.definition.id}.workflow`);
|
|
6778
|
+
return workflow;
|
|
6779
|
+
}
|
|
6780
|
+
function validateCustomLoopTemplateFile(file) {
|
|
6781
|
+
const source = resolve(file);
|
|
6782
|
+
const entry = readCustomTemplateFile(source);
|
|
6783
|
+
const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== source);
|
|
6784
|
+
assertNoTemplateCollisions([...existing, entry]);
|
|
6785
|
+
return structuredClone(entry.summary);
|
|
6786
|
+
}
|
|
6787
|
+
function importCustomLoopTemplate(file, opts = {}) {
|
|
6788
|
+
const source = resolve(file);
|
|
6789
|
+
const entry = readCustomTemplateFile(source);
|
|
6790
|
+
const dir = ensureCustomLoopTemplatesDir();
|
|
6791
|
+
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
6792
|
+
const replaced = existsSync4(destination);
|
|
6793
|
+
const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== resolve(destination));
|
|
6794
|
+
assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
|
|
6795
|
+
if (replaced) {
|
|
6796
|
+
const stat = lstatSync(destination);
|
|
6797
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
6798
|
+
throw new Error(`refusing to replace non-regular custom template file: ${destination}`);
|
|
6799
|
+
if (!opts.replace)
|
|
6800
|
+
throw new Error(`custom template already exists: ${entry.definition.id}; use --replace to overwrite it`);
|
|
6801
|
+
}
|
|
6802
|
+
writeFileSync4(destination, `${JSON.stringify(entry.definition, null, 2)}
|
|
6803
|
+
`, { mode: 384 });
|
|
6804
|
+
const imported = readCustomTemplateFile(destination);
|
|
6805
|
+
return { template: structuredClone(imported.summary), path: destination, replaced };
|
|
6806
|
+
}
|
|
6807
|
+
function validateLoopTemplateRegistry(opts = {}) {
|
|
6808
|
+
return {
|
|
6809
|
+
ok: true,
|
|
6810
|
+
templates: listLoopTemplates(opts),
|
|
6811
|
+
customDir: customLoopTemplatesDir()
|
|
6812
|
+
};
|
|
6813
|
+
}
|
|
6814
|
+
function listLoopTemplates(opts = {}) {
|
|
6815
|
+
const source = opts.source ?? "all";
|
|
6816
|
+
const templates = [];
|
|
6817
|
+
if (source !== "custom")
|
|
6818
|
+
templates.push(...builtinLoopTemplates());
|
|
6819
|
+
if (source !== "builtin")
|
|
6820
|
+
templates.push(...loadCustomLoopTemplates().map((entry) => structuredClone(entry.summary)));
|
|
6821
|
+
return templates;
|
|
6822
|
+
}
|
|
6823
|
+
function getLoopTemplate(id, opts = {}) {
|
|
6824
|
+
const source = opts.source ?? "all";
|
|
6825
|
+
if (source !== "custom") {
|
|
6826
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
6827
|
+
if (builtin)
|
|
6828
|
+
return builtin;
|
|
6829
|
+
if (source === "builtin")
|
|
6830
|
+
return;
|
|
6831
|
+
}
|
|
6832
|
+
return getCustomLoopTemplate(id)?.summary;
|
|
6484
6833
|
}
|
|
6485
6834
|
function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
6486
6835
|
if (!input.taskId?.trim())
|
|
@@ -6825,7 +7174,7 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
|
6825
7174
|
]
|
|
6826
7175
|
};
|
|
6827
7176
|
}
|
|
6828
|
-
function
|
|
7177
|
+
function renderBuiltinLoopTemplate(id, values) {
|
|
6829
7178
|
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
6830
7179
|
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
6831
7180
|
}
|
|
@@ -6939,6 +7288,20 @@ function booleanVar(value) {
|
|
|
6939
7288
|
function accountPoolVar(value, tool) {
|
|
6940
7289
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
6941
7290
|
}
|
|
7291
|
+
function renderLoopTemplate(id, values, opts = {}) {
|
|
7292
|
+
const source = opts.source ?? "all";
|
|
7293
|
+
if (source !== "custom") {
|
|
7294
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
7295
|
+
if (builtin)
|
|
7296
|
+
return renderBuiltinLoopTemplate(builtin.id, values);
|
|
7297
|
+
if (source === "builtin")
|
|
7298
|
+
throw new Error(`unknown built-in template: ${id}`);
|
|
7299
|
+
}
|
|
7300
|
+
const custom = getCustomLoopTemplate(id);
|
|
7301
|
+
if (custom)
|
|
7302
|
+
return renderCustomLoopTemplate(custom, values);
|
|
7303
|
+
throw new Error(`unknown template: ${id}`);
|
|
7304
|
+
}
|
|
6942
7305
|
|
|
6943
7306
|
// src/cli/index.ts
|
|
6944
7307
|
var program = new Command;
|
|
@@ -7230,7 +7593,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
|
7230
7593
|
return {
|
|
7231
7594
|
ok: result.status === 0,
|
|
7232
7595
|
status: result.status,
|
|
7233
|
-
stdout:
|
|
7596
|
+
stdout: readFileSync3(stdoutPath, "utf8"),
|
|
7234
7597
|
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
|
|
7235
7598
|
error: result.error ? String(result.error.message || result.error) : ""
|
|
7236
7599
|
};
|
|
@@ -7252,11 +7615,11 @@ function ensureTodosTaskList(project, slug, name, description) {
|
|
|
7252
7615
|
function backupLoopsDatabase(reason) {
|
|
7253
7616
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
7254
7617
|
const backupDir = join6(dataDir(), "backups");
|
|
7255
|
-
|
|
7618
|
+
mkdirSync7(backupDir, { recursive: true, mode: 448 });
|
|
7256
7619
|
const backupPath = join6(backupDir, `loops.db.bak-${reason}-${stamp}`);
|
|
7257
7620
|
const db = new Database2(dbPath(), { readonly: true });
|
|
7258
7621
|
try {
|
|
7259
|
-
|
|
7622
|
+
writeFileSync5(backupPath, db.serialize(), { mode: 384 });
|
|
7260
7623
|
} finally {
|
|
7261
7624
|
db.close();
|
|
7262
7625
|
}
|
|
@@ -7274,7 +7637,7 @@ function readRouteCursors() {
|
|
|
7274
7637
|
if (!existsSync5(path))
|
|
7275
7638
|
return {};
|
|
7276
7639
|
try {
|
|
7277
|
-
const parsed = JSON.parse(
|
|
7640
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
7278
7641
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
7279
7642
|
} catch {
|
|
7280
7643
|
return {};
|
|
@@ -7285,15 +7648,15 @@ function writeRouteCursor(key, lastFingerprint) {
|
|
|
7285
7648
|
return;
|
|
7286
7649
|
const cursors = readRouteCursors();
|
|
7287
7650
|
cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
|
|
7288
|
-
|
|
7651
|
+
writeFileSync5(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
|
|
7289
7652
|
}
|
|
7290
7653
|
function writeRouteEvidence(kind, value, evidenceDir) {
|
|
7291
7654
|
if (!evidenceDir)
|
|
7292
7655
|
return;
|
|
7293
|
-
|
|
7656
|
+
mkdirSync7(evidenceDir, { recursive: true, mode: 448 });
|
|
7294
7657
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
7295
7658
|
const evidencePath = join6(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
7296
|
-
|
|
7659
|
+
writeFileSync5(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
7297
7660
|
return evidencePath;
|
|
7298
7661
|
}
|
|
7299
7662
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -7686,7 +8049,7 @@ function routeThrottleDryRunPreview(args) {
|
|
|
7686
8049
|
};
|
|
7687
8050
|
}
|
|
7688
8051
|
async function readEventEnvelopeInput(opts = {}) {
|
|
7689
|
-
const raw = opts.eventJson ?? (opts.eventFile ?
|
|
8052
|
+
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync3(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
7690
8053
|
const event = JSON.parse(raw);
|
|
7691
8054
|
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
7692
8055
|
throw new Error("event JSON must be an object");
|
|
@@ -8599,9 +8962,20 @@ function formatTemplateVariable(template, name) {
|
|
|
8599
8962
|
const placeholder = variable?.default ? variable.default : `<${name}>`;
|
|
8600
8963
|
return ` --var ${name}=${placeholder}`;
|
|
8601
8964
|
}
|
|
8965
|
+
function templateSource(value) {
|
|
8966
|
+
const source = value ?? "all";
|
|
8967
|
+
if (source === "all" || source === "builtin" || source === "custom")
|
|
8968
|
+
return source;
|
|
8969
|
+
throw new Error("--source must be all, builtin, or custom");
|
|
8970
|
+
}
|
|
8971
|
+
function addTemplateSourceOption(command, defaultValue = "all") {
|
|
8972
|
+
return command.option("--source <source>", "template source: all, builtin, or custom", defaultValue);
|
|
8973
|
+
}
|
|
8602
8974
|
function printTemplateDetails(template) {
|
|
8603
8975
|
console.log(`${template.id} (${template.kind})`);
|
|
8604
8976
|
console.log(template.name);
|
|
8977
|
+
if (template.source)
|
|
8978
|
+
console.log(`source: ${template.source}${template.sourcePath ? ` (${template.sourcePath})` : ""}`);
|
|
8605
8979
|
console.log("");
|
|
8606
8980
|
console.log(template.description);
|
|
8607
8981
|
console.log("");
|
|
@@ -8645,18 +9019,31 @@ function printWorkflowListWarning(args) {
|
|
|
8645
9019
|
const next = args.limit && nextOffset < args.total ? ` next page: --limit ${args.limit} --offset ${nextOffset}` : "";
|
|
8646
9020
|
console.error(`showing ${args.offset + args.shown} of ${args.total} ${scope} workflows.${next}`);
|
|
8647
9021
|
}
|
|
8648
|
-
templates.command("list").alias("ls").description("list
|
|
8649
|
-
const values = listLoopTemplates();
|
|
9022
|
+
templates.command("list").alias("ls").description("list OpenLoops templates").option("--source <source>", "template source: all, builtin, or custom", "all").action((opts) => {
|
|
9023
|
+
const values = listLoopTemplates({ source: templateSource(opts.source) });
|
|
8650
9024
|
if (isJson())
|
|
8651
9025
|
print(values);
|
|
8652
9026
|
else {
|
|
8653
9027
|
for (const template of values) {
|
|
8654
|
-
console.log(`${template.id} ${template.kind} ${template.description}`);
|
|
9028
|
+
console.log(`${template.id} ${template.source ?? "builtin"} ${template.kind} ${template.description}`);
|
|
8655
9029
|
}
|
|
8656
9030
|
}
|
|
8657
9031
|
});
|
|
8658
|
-
templates.command("
|
|
8659
|
-
const
|
|
9032
|
+
templates.command("import <file>").alias("add").description("import a custom workflow template JSON file into the local registry").option("--replace", "replace an existing custom template with the same id").action((file, opts) => {
|
|
9033
|
+
const result = importCustomLoopTemplate(file, { replace: Boolean(opts.replace) });
|
|
9034
|
+
print(result, `${result.replaced ? "replaced" : "imported"} custom template ${result.template.id} -> ${result.path}`);
|
|
9035
|
+
});
|
|
9036
|
+
templates.command("validate [file]").description("validate a custom template JSON file or the local template registry").option("--source <source>", "template source to validate when no file is given: all, builtin, or custom", "all").action((file, opts) => {
|
|
9037
|
+
if (file) {
|
|
9038
|
+
const template = validateCustomLoopTemplateFile(file);
|
|
9039
|
+
print({ ok: true, template, customDir: loopTemplatesDir() }, `valid custom template ${template.id}`);
|
|
9040
|
+
return;
|
|
9041
|
+
}
|
|
9042
|
+
const result = validateLoopTemplateRegistry({ source: templateSource(opts.source) });
|
|
9043
|
+
print(result, `valid template registry (${result.templates.length} templates) customDir=${result.customDir}`);
|
|
9044
|
+
});
|
|
9045
|
+
addTemplateSourceOption(templates.command("show <id>").description("show a template")).action((id, opts) => {
|
|
9046
|
+
const template = getLoopTemplate(id, { source: templateSource(opts.source) });
|
|
8660
9047
|
if (!template)
|
|
8661
9048
|
throw new Error(`template not found: ${id}`);
|
|
8662
9049
|
if (isJson())
|
|
@@ -8664,14 +9051,14 @@ templates.command("show <id>").description("show a built-in template").action((i
|
|
|
8664
9051
|
else
|
|
8665
9052
|
printTemplateDetails(template);
|
|
8666
9053
|
});
|
|
8667
|
-
templates.command("render <id>").description("render a template as workflow JSON").option("--var <key=value>", "template variable; may be repeated", collectValues, []).action((id, opts) => {
|
|
8668
|
-
const workflow = renderLoopTemplate(id, parseVars(opts.var));
|
|
9054
|
+
addTemplateSourceOption(templates.command("render <id>").description("render a template as workflow JSON").option("--var <key=value>", "template variable; may be repeated", collectValues, [])).action((id, opts) => {
|
|
9055
|
+
const workflow = renderLoopTemplate(id, parseVars(opts.var), { source: templateSource(opts.source) });
|
|
8669
9056
|
print(workflow, JSON.stringify(workflow, null, 2));
|
|
8670
9057
|
});
|
|
8671
|
-
templates.command("create-workflow <id>").description("render and store a template as a workflow").option("--var <key=value>", "template variable; may be repeated", collectValues, []).action((id, opts) => {
|
|
9058
|
+
addTemplateSourceOption(templates.command("create-workflow <id>").description("render and store a template as a workflow").option("--var <key=value>", "template variable; may be repeated", collectValues, [])).action((id, opts) => {
|
|
8672
9059
|
const store = new Store;
|
|
8673
9060
|
try {
|
|
8674
|
-
const body = renderLoopTemplate(id, parseVars(opts.var));
|
|
9061
|
+
const body = renderLoopTemplate(id, parseVars(opts.var), { source: templateSource(opts.source) });
|
|
8675
9062
|
const workflow = store.createWorkflow(body);
|
|
8676
9063
|
print(publicWorkflow(workflow), `created workflow ${workflow.id} (${workflow.name}) steps=${workflow.steps.length}`);
|
|
8677
9064
|
} finally {
|
|
@@ -8843,7 +9230,7 @@ machines.command("show <id>").description("resolve a machine assignment").action
|
|
|
8843
9230
|
print(resolveLoopMachine(id));
|
|
8844
9231
|
});
|
|
8845
9232
|
workflows.command("validate <file>").description("validate a workflow JSON file without storing or running it").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables").action((file, opts) => {
|
|
8846
|
-
const body = workflowBodyFromJson(JSON.parse(
|
|
9233
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync3(file, "utf8")), opts.name);
|
|
8847
9234
|
const workflow = workflowSpecForPreflight(body);
|
|
8848
9235
|
const preflight = opts.preflight ? preflightWorkflow(workflow) : undefined;
|
|
8849
9236
|
print({ valid: true, workflow: publicWorkflow(workflow), preflight }, `valid workflow ${workflow.name} steps=${workflow.steps.length}`);
|
|
@@ -8851,7 +9238,7 @@ workflows.command("validate <file>").description("validate a workflow JSON file
|
|
|
8851
9238
|
workflows.command("create <file>").description("validate and store a workflow JSON file").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables before storing").action((file, opts) => {
|
|
8852
9239
|
const store = new Store;
|
|
8853
9240
|
try {
|
|
8854
|
-
const body = workflowBodyFromJson(JSON.parse(
|
|
9241
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync3(file, "utf8")), opts.name);
|
|
8855
9242
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(body, "creation-preflight"), { name: body.name, type: "workflow" }, {}) : undefined;
|
|
8856
9243
|
const workflow = store.createWorkflow(body);
|
|
8857
9244
|
if (preflight !== undefined)
|
|
@@ -9485,6 +9872,43 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
9485
9872
|
program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
|
|
9486
9873
|
program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
|
|
9487
9874
|
program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
|
|
9875
|
+
program.command("rename <idOrName> <newName>").description("rename a loop without changing its id, schedule, runs, or history").action((idOrName, newName) => {
|
|
9876
|
+
const store = new Store;
|
|
9877
|
+
try {
|
|
9878
|
+
const loop = store.requireLoop(idOrName);
|
|
9879
|
+
const oldName = loop.name;
|
|
9880
|
+
const trimmed = String(newName).trim();
|
|
9881
|
+
if (!trimmed)
|
|
9882
|
+
throw new Error("loop name must not be empty");
|
|
9883
|
+
const existing = store.findLoopByName(trimmed);
|
|
9884
|
+
if (existing && existing.id !== loop.id) {
|
|
9885
|
+
throw new Error(`loop name already exists: ${trimmed} (${existing.id})`);
|
|
9886
|
+
}
|
|
9887
|
+
if (trimmed === oldName) {
|
|
9888
|
+
print({
|
|
9889
|
+
changed: false,
|
|
9890
|
+
id: loop.id,
|
|
9891
|
+
oldName,
|
|
9892
|
+
newName: oldName,
|
|
9893
|
+
loop: publicLoop(loop)
|
|
9894
|
+
}, `${loop.id} unchanged (${oldName})`);
|
|
9895
|
+
return;
|
|
9896
|
+
}
|
|
9897
|
+
const backupPath = backupLoopsDatabase("rename");
|
|
9898
|
+
const renamed = store.renameLoop(loop.id, trimmed);
|
|
9899
|
+
print({
|
|
9900
|
+
changed: true,
|
|
9901
|
+
id: renamed.id,
|
|
9902
|
+
oldName,
|
|
9903
|
+
newName: renamed.name,
|
|
9904
|
+
backupPath,
|
|
9905
|
+
loop: publicLoop(renamed)
|
|
9906
|
+
}, `${renamed.id} renamed ${oldName} -> ${renamed.name}
|
|
9907
|
+
backup=${backupPath}`);
|
|
9908
|
+
} finally {
|
|
9909
|
+
store.close();
|
|
9910
|
+
}
|
|
9911
|
+
});
|
|
9488
9912
|
function updateStatus(idOrName, status) {
|
|
9489
9913
|
const store = new Store;
|
|
9490
9914
|
try {
|
|
@@ -9623,7 +10047,7 @@ daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) =>
|
|
|
9623
10047
|
console.log("");
|
|
9624
10048
|
return;
|
|
9625
10049
|
}
|
|
9626
|
-
const lines =
|
|
10050
|
+
const lines = readFileSync3(path, "utf8").trimEnd().split(`
|
|
9627
10051
|
`);
|
|
9628
10052
|
console.log(lines.slice(-Number(opts.lines)).join(`
|
|
9629
10053
|
`));
|
package/dist/daemon/index.js
CHANGED
|
@@ -5240,7 +5240,7 @@ function enableStartup(result) {
|
|
|
5240
5240
|
// package.json
|
|
5241
5241
|
var package_default = {
|
|
5242
5242
|
name: "@hasna/loops",
|
|
5243
|
-
version: "0.3.
|
|
5243
|
+
version: "0.3.51",
|
|
5244
5244
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5245
5245
|
type: "module",
|
|
5246
5246
|
main: "dist/index.js",
|
package/dist/index.js
CHANGED
|
@@ -4977,7 +4977,7 @@ function openAutomationsRuntimeBinding(overrides = {}) {
|
|
|
4977
4977
|
}
|
|
4978
4978
|
// src/lib/templates.ts
|
|
4979
4979
|
import { execFileSync } from "child_process";
|
|
4980
|
-
import { existsSync as existsSync3 } from "fs";
|
|
4980
|
+
import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readdirSync, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
4981
4981
|
import { homedir as homedir3 } from "os";
|
|
4982
4982
|
import { basename as basename2, isAbsolute, join as join5, relative, resolve } from "path";
|
|
4983
4983
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
@@ -5178,6 +5178,16 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5178
5178
|
]
|
|
5179
5179
|
}
|
|
5180
5180
|
];
|
|
5181
|
+
var CUSTOM_TEMPLATE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
|
5182
|
+
var CUSTOM_TEMPLATE_VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
5183
|
+
var CUSTOM_TEMPLATE_VARIABLE_TYPES = new Set(["string", "number", "boolean", "json", "string[]"]);
|
|
5184
|
+
var CUSTOM_TEMPLATE_PLACEHOLDER = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
|
|
5185
|
+
var CUSTOM_TEMPLATE_EXACT_PLACEHOLDER = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/;
|
|
5186
|
+
var CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS = [
|
|
5187
|
+
"danger-full-access",
|
|
5188
|
+
"dangerously-bypass",
|
|
5189
|
+
"dangerously-skip"
|
|
5190
|
+
];
|
|
5181
5191
|
function compactJson(value) {
|
|
5182
5192
|
return JSON.stringify(value);
|
|
5183
5193
|
}
|
|
@@ -5455,11 +5465,350 @@ function workflowStepsWithWorktree(plan, steps) {
|
|
|
5455
5465
|
...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
|
|
5456
5466
|
];
|
|
5457
5467
|
}
|
|
5458
|
-
function
|
|
5459
|
-
|
|
5468
|
+
function assertRecord(value, label) {
|
|
5469
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5470
|
+
throw new Error(`${label} must be an object`);
|
|
5471
|
+
}
|
|
5472
|
+
function assertTemplateString(value, label) {
|
|
5473
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
5474
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
5475
|
+
return value.trim();
|
|
5476
|
+
}
|
|
5477
|
+
function assertTemplateKind(value, label) {
|
|
5478
|
+
const kind = assertTemplateString(value, label);
|
|
5479
|
+
if (kind !== "workflow")
|
|
5480
|
+
throw new Error(`${label} must be workflow; custom loop templates are not supported yet`);
|
|
5481
|
+
return kind;
|
|
5482
|
+
}
|
|
5483
|
+
function customLoopTemplatesDir() {
|
|
5484
|
+
return join5(dataDir(), "templates");
|
|
5485
|
+
}
|
|
5486
|
+
function ensureCustomLoopTemplatesDir() {
|
|
5487
|
+
const dir = customLoopTemplatesDir();
|
|
5488
|
+
mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
5489
|
+
return dir;
|
|
5490
|
+
}
|
|
5491
|
+
function loopTemplatesDir() {
|
|
5492
|
+
return customLoopTemplatesDir();
|
|
5493
|
+
}
|
|
5494
|
+
function builtinLoopTemplates() {
|
|
5495
|
+
return TEMPLATE_SUMMARIES.map((template) => ({ ...structuredClone(template), source: "builtin" }));
|
|
5496
|
+
}
|
|
5497
|
+
function getBuiltinLoopTemplate(id) {
|
|
5498
|
+
return builtinLoopTemplates().find((template) => template.id === id || template.name === id);
|
|
5499
|
+
}
|
|
5500
|
+
function builtinTemplateKeys() {
|
|
5501
|
+
const keys = new Set;
|
|
5502
|
+
for (const template of TEMPLATE_SUMMARIES) {
|
|
5503
|
+
keys.add(template.id);
|
|
5504
|
+
keys.add(template.name);
|
|
5505
|
+
}
|
|
5506
|
+
return keys;
|
|
5507
|
+
}
|
|
5508
|
+
function validateCustomTemplateId(id, label) {
|
|
5509
|
+
if (!CUSTOM_TEMPLATE_ID_PATTERN.test(id)) {
|
|
5510
|
+
throw new Error(`${label} must match ${CUSTOM_TEMPLATE_ID_PATTERN.source}`);
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
function optionalTemplateBoolean(value, label) {
|
|
5514
|
+
if (value === undefined)
|
|
5515
|
+
return;
|
|
5516
|
+
if (typeof value !== "boolean")
|
|
5517
|
+
throw new Error(`${label} must be a boolean`);
|
|
5518
|
+
return value;
|
|
5519
|
+
}
|
|
5520
|
+
function validateCustomTemplateVariables(value, label) {
|
|
5521
|
+
if (value === undefined)
|
|
5522
|
+
return [];
|
|
5523
|
+
if (!Array.isArray(value))
|
|
5524
|
+
throw new Error(`${label} must be an array`);
|
|
5525
|
+
const seen = new Set;
|
|
5526
|
+
return value.map((entry, index) => {
|
|
5527
|
+
const entryLabel = `${label}[${index}]`;
|
|
5528
|
+
assertRecord(entry, entryLabel);
|
|
5529
|
+
const name = assertTemplateString(entry.name, `${entryLabel}.name`);
|
|
5530
|
+
if (!CUSTOM_TEMPLATE_VARIABLE_PATTERN.test(name)) {
|
|
5531
|
+
throw new Error(`${entryLabel}.name must match ${CUSTOM_TEMPLATE_VARIABLE_PATTERN.source}`);
|
|
5532
|
+
}
|
|
5533
|
+
if (seen.has(name))
|
|
5534
|
+
throw new Error(`duplicate custom template variable: ${name}`);
|
|
5535
|
+
seen.add(name);
|
|
5536
|
+
const description = entry.description === undefined ? undefined : assertTemplateString(entry.description, `${entryLabel}.description`);
|
|
5537
|
+
const defaultValue = entry.default === undefined ? undefined : assertTemplateString(entry.default, `${entryLabel}.default`);
|
|
5538
|
+
const type = entry.type === undefined ? undefined : assertTemplateString(entry.type, `${entryLabel}.type`);
|
|
5539
|
+
if (type && !CUSTOM_TEMPLATE_VARIABLE_TYPES.has(type)) {
|
|
5540
|
+
throw new Error(`${entryLabel}.type must be one of ${[...CUSTOM_TEMPLATE_VARIABLE_TYPES].join(", ")}`);
|
|
5541
|
+
}
|
|
5542
|
+
if (defaultValue && CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS.some((pattern) => defaultValue.includes(pattern))) {
|
|
5543
|
+
throw new Error(`${entryLabel}.default cannot contain dangerous sandbox or bypass flags in a custom template`);
|
|
5544
|
+
}
|
|
5545
|
+
return {
|
|
5546
|
+
name,
|
|
5547
|
+
description,
|
|
5548
|
+
required: optionalTemplateBoolean(entry.required, `${entryLabel}.required`),
|
|
5549
|
+
default: defaultValue,
|
|
5550
|
+
type
|
|
5551
|
+
};
|
|
5552
|
+
});
|
|
5553
|
+
}
|
|
5554
|
+
function hasDangerousArg(value) {
|
|
5555
|
+
return CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS.some((pattern) => value.includes(pattern));
|
|
5556
|
+
}
|
|
5557
|
+
function assertNoDangerousCustomTemplateScalars(value, label) {
|
|
5558
|
+
if (typeof value === "string") {
|
|
5559
|
+
if (hasDangerousArg(value)) {
|
|
5560
|
+
throw new Error(`${label} contains a dangerous sandbox or bypass flag; custom templates must not request danger-full-access`);
|
|
5561
|
+
}
|
|
5562
|
+
return;
|
|
5563
|
+
}
|
|
5564
|
+
if (!value || typeof value !== "object")
|
|
5565
|
+
return;
|
|
5566
|
+
if (Array.isArray(value)) {
|
|
5567
|
+
value.forEach((entry, index) => assertNoDangerousCustomTemplateScalars(entry, `${label}[${index}]`));
|
|
5568
|
+
return;
|
|
5569
|
+
}
|
|
5570
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
5571
|
+
assertNoDangerousCustomTemplateScalars(entry, `${label}.${key}`);
|
|
5572
|
+
}
|
|
5573
|
+
}
|
|
5574
|
+
function assertNoImplicitDangerFullAccess(value, label) {
|
|
5575
|
+
if (!value || typeof value !== "object")
|
|
5576
|
+
return;
|
|
5577
|
+
if (Array.isArray(value)) {
|
|
5578
|
+
value.forEach((entry, index) => assertNoImplicitDangerFullAccess(entry, `${label}[${index}]`));
|
|
5579
|
+
return;
|
|
5580
|
+
}
|
|
5581
|
+
const object = value;
|
|
5582
|
+
if (object.type === "agent" && (object.provider === "codewith" || object.provider === "codex") && object.permissionMode === "bypass" && object.sandbox === undefined) {
|
|
5583
|
+
throw new Error(`${label} uses permissionMode=bypass for ${object.provider} without an explicit sandbox; set sandbox=workspace-write or read-only`);
|
|
5584
|
+
}
|
|
5585
|
+
for (const [key, entry] of Object.entries(object)) {
|
|
5586
|
+
assertNoImplicitDangerFullAccess(entry, `${label}.${key}`);
|
|
5587
|
+
}
|
|
5588
|
+
}
|
|
5589
|
+
function assertCustomTemplateSafety(value, label) {
|
|
5590
|
+
assertNoDangerousCustomTemplateScalars(value, label);
|
|
5591
|
+
assertNoImplicitDangerFullAccess(value, label);
|
|
5592
|
+
}
|
|
5593
|
+
function customTemplateDefinitionFromJson(value, sourcePath) {
|
|
5594
|
+
assertRecord(value, sourcePath);
|
|
5595
|
+
const id = assertTemplateString(value.id, `${sourcePath}.id`);
|
|
5596
|
+
validateCustomTemplateId(id, `${sourcePath}.id`);
|
|
5597
|
+
const name = assertTemplateString(value.name, `${sourcePath}.name`);
|
|
5598
|
+
const description = assertTemplateString(value.description, `${sourcePath}.description`);
|
|
5599
|
+
const kind = assertTemplateKind(value.kind ?? "workflow", `${sourcePath}.kind`);
|
|
5600
|
+
const variables = validateCustomTemplateVariables(value.variables, `${sourcePath}.variables`);
|
|
5601
|
+
if (value.workflow === undefined)
|
|
5602
|
+
throw new Error(`${sourcePath}.workflow is required`);
|
|
5603
|
+
assertRecord(value.workflow, `${sourcePath}.workflow`);
|
|
5604
|
+
assertCustomTemplateSafety(value.workflow, `${sourcePath}.workflow`);
|
|
5605
|
+
return { id, name, description, kind, variables, workflow: value.workflow };
|
|
5606
|
+
}
|
|
5607
|
+
function customTemplateSummary(definition, sourcePath) {
|
|
5608
|
+
return {
|
|
5609
|
+
id: definition.id,
|
|
5610
|
+
name: definition.name,
|
|
5611
|
+
description: definition.description,
|
|
5612
|
+
kind: definition.kind,
|
|
5613
|
+
variables: structuredClone(definition.variables),
|
|
5614
|
+
source: "custom",
|
|
5615
|
+
sourcePath
|
|
5616
|
+
};
|
|
5617
|
+
}
|
|
5618
|
+
function readCustomTemplateFile(file) {
|
|
5619
|
+
let parsed;
|
|
5620
|
+
try {
|
|
5621
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
5622
|
+
} catch (error) {
|
|
5623
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5624
|
+
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
5625
|
+
}
|
|
5626
|
+
const definition = customTemplateDefinitionFromJson(parsed, file);
|
|
5627
|
+
return { definition, summary: customTemplateSummary(definition, file), path: file };
|
|
5628
|
+
}
|
|
5629
|
+
function assertNoTemplateCollisions(entries) {
|
|
5630
|
+
const builtinKeys = builtinTemplateKeys();
|
|
5631
|
+
const seen = new Map;
|
|
5632
|
+
for (const entry of entries) {
|
|
5633
|
+
for (const key of [entry.definition.id, entry.definition.name]) {
|
|
5634
|
+
if (builtinKeys.has(key)) {
|
|
5635
|
+
throw new Error(`custom template ${entry.definition.id} collides with built-in template key ${key}; choose a different id or name`);
|
|
5636
|
+
}
|
|
5637
|
+
const existing = seen.get(key);
|
|
5638
|
+
if (existing) {
|
|
5639
|
+
throw new Error(`custom template ${entry.definition.id} collides with ${existing} on key ${key}`);
|
|
5640
|
+
}
|
|
5641
|
+
seen.set(key, entry.definition.id);
|
|
5642
|
+
}
|
|
5643
|
+
}
|
|
5644
|
+
}
|
|
5645
|
+
function loadCustomLoopTemplatesRaw() {
|
|
5646
|
+
const dir = customLoopTemplatesDir();
|
|
5647
|
+
if (!existsSync3(dir))
|
|
5648
|
+
return [];
|
|
5649
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
5650
|
+
const file = join5(dir, entry.name);
|
|
5651
|
+
if (entry.isSymbolicLink())
|
|
5652
|
+
throw new Error(`refusing symlinked custom template file: ${file}`);
|
|
5653
|
+
if (!entry.isFile())
|
|
5654
|
+
throw new Error(`custom template registry entry is not a regular file: ${file}`);
|
|
5655
|
+
return readCustomTemplateFile(file);
|
|
5656
|
+
});
|
|
5657
|
+
}
|
|
5658
|
+
function loadCustomLoopTemplates() {
|
|
5659
|
+
const entries = loadCustomLoopTemplatesRaw();
|
|
5660
|
+
assertNoTemplateCollisions(entries);
|
|
5661
|
+
return entries;
|
|
5662
|
+
}
|
|
5663
|
+
function getCustomLoopTemplate(id) {
|
|
5664
|
+
return loadCustomLoopTemplates().find((template) => template.definition.id === id || template.definition.name === id);
|
|
5665
|
+
}
|
|
5666
|
+
function coerceCustomTemplateValue(raw, type, label) {
|
|
5667
|
+
const normalizedType = type ?? "string";
|
|
5668
|
+
if (normalizedType === "string")
|
|
5669
|
+
return String(raw);
|
|
5670
|
+
if (normalizedType === "number") {
|
|
5671
|
+
const value = typeof raw === "number" ? raw : Number(String(raw));
|
|
5672
|
+
if (!Number.isFinite(value))
|
|
5673
|
+
throw new Error(`${label} must be a finite number`);
|
|
5674
|
+
return value;
|
|
5675
|
+
}
|
|
5676
|
+
if (normalizedType === "boolean") {
|
|
5677
|
+
if (typeof raw === "boolean")
|
|
5678
|
+
return raw;
|
|
5679
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
5680
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
5681
|
+
return true;
|
|
5682
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
5683
|
+
return false;
|
|
5684
|
+
throw new Error(`${label} must be a boolean`);
|
|
5685
|
+
}
|
|
5686
|
+
if (normalizedType === "json") {
|
|
5687
|
+
if (typeof raw !== "string")
|
|
5688
|
+
return raw;
|
|
5689
|
+
try {
|
|
5690
|
+
return JSON.parse(raw);
|
|
5691
|
+
} catch (error) {
|
|
5692
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5693
|
+
throw new Error(`${label} must be valid JSON: ${message}`);
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
if (normalizedType === "string[]") {
|
|
5697
|
+
if (Array.isArray(raw))
|
|
5698
|
+
return raw.map((entry) => String(entry));
|
|
5699
|
+
return String(raw).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5700
|
+
}
|
|
5701
|
+
return String(raw);
|
|
5702
|
+
}
|
|
5703
|
+
function customTemplateValues(definition, values) {
|
|
5704
|
+
const variablesByName = new Map(definition.variables.map((variable) => [variable.name, variable]));
|
|
5705
|
+
for (const [name, value] of Object.entries(values)) {
|
|
5706
|
+
if (value !== undefined && !variablesByName.has(name)) {
|
|
5707
|
+
throw new Error(`unknown variable for custom template ${definition.id}: ${name}`);
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
const rendered = {};
|
|
5711
|
+
for (const variable of definition.variables) {
|
|
5712
|
+
const raw = values[variable.name] ?? variable.default;
|
|
5713
|
+
if (raw === undefined || raw === "") {
|
|
5714
|
+
if (variable.required)
|
|
5715
|
+
throw new Error(`${variable.name} is required`);
|
|
5716
|
+
continue;
|
|
5717
|
+
}
|
|
5718
|
+
rendered[variable.name] = coerceCustomTemplateValue(raw, variable.type, variable.name);
|
|
5719
|
+
}
|
|
5720
|
+
return rendered;
|
|
5460
5721
|
}
|
|
5461
|
-
function
|
|
5462
|
-
|
|
5722
|
+
function customTemplateValueForPlaceholder(values, name, templateId) {
|
|
5723
|
+
if (!(name in values))
|
|
5724
|
+
throw new Error(`custom template ${templateId} requires variable ${name}`);
|
|
5725
|
+
return values[name];
|
|
5726
|
+
}
|
|
5727
|
+
function stringifyCustomTemplateValue(value, name) {
|
|
5728
|
+
if (typeof value === "string")
|
|
5729
|
+
return value;
|
|
5730
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
5731
|
+
return String(value);
|
|
5732
|
+
if (value === null)
|
|
5733
|
+
return "null";
|
|
5734
|
+
if (Array.isArray(value) || typeof value === "object")
|
|
5735
|
+
return JSON.stringify(value);
|
|
5736
|
+
throw new Error(`custom template variable ${name} cannot be rendered as a string`);
|
|
5737
|
+
}
|
|
5738
|
+
function renderCustomTemplateNode(value, values, templateId) {
|
|
5739
|
+
if (typeof value === "string") {
|
|
5740
|
+
const exact = CUSTOM_TEMPLATE_EXACT_PLACEHOLDER.exec(value);
|
|
5741
|
+
if (exact)
|
|
5742
|
+
return customTemplateValueForPlaceholder(values, exact[1], templateId);
|
|
5743
|
+
return value.replace(CUSTOM_TEMPLATE_PLACEHOLDER, (_match, name) => stringifyCustomTemplateValue(customTemplateValueForPlaceholder(values, name, templateId), name));
|
|
5744
|
+
}
|
|
5745
|
+
if (Array.isArray(value))
|
|
5746
|
+
return value.map((entry) => renderCustomTemplateNode(entry, values, templateId));
|
|
5747
|
+
if (!value || typeof value !== "object")
|
|
5748
|
+
return value;
|
|
5749
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderCustomTemplateNode(entry, values, templateId)]));
|
|
5750
|
+
}
|
|
5751
|
+
function renderCustomLoopTemplate(entry, values) {
|
|
5752
|
+
const renderedValues = customTemplateValues(entry.definition, values);
|
|
5753
|
+
const rendered = renderCustomTemplateNode(entry.definition.workflow, renderedValues, entry.definition.id);
|
|
5754
|
+
assertCustomTemplateSafety(rendered, `custom template ${entry.definition.id}.workflow`);
|
|
5755
|
+
const workflow = workflowBodyFromJson(rendered);
|
|
5756
|
+
assertCustomTemplateSafety(workflow, `custom template ${entry.definition.id}.workflow`);
|
|
5757
|
+
return workflow;
|
|
5758
|
+
}
|
|
5759
|
+
function validateCustomLoopTemplateFile(file) {
|
|
5760
|
+
const source = resolve(file);
|
|
5761
|
+
const entry = readCustomTemplateFile(source);
|
|
5762
|
+
const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== source);
|
|
5763
|
+
assertNoTemplateCollisions([...existing, entry]);
|
|
5764
|
+
return structuredClone(entry.summary);
|
|
5765
|
+
}
|
|
5766
|
+
function importCustomLoopTemplate(file, opts = {}) {
|
|
5767
|
+
const source = resolve(file);
|
|
5768
|
+
const entry = readCustomTemplateFile(source);
|
|
5769
|
+
const dir = ensureCustomLoopTemplatesDir();
|
|
5770
|
+
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
5771
|
+
const replaced = existsSync3(destination);
|
|
5772
|
+
const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== resolve(destination));
|
|
5773
|
+
assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
|
|
5774
|
+
if (replaced) {
|
|
5775
|
+
const stat = lstatSync(destination);
|
|
5776
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
5777
|
+
throw new Error(`refusing to replace non-regular custom template file: ${destination}`);
|
|
5778
|
+
if (!opts.replace)
|
|
5779
|
+
throw new Error(`custom template already exists: ${entry.definition.id}; use --replace to overwrite it`);
|
|
5780
|
+
}
|
|
5781
|
+
writeFileSync2(destination, `${JSON.stringify(entry.definition, null, 2)}
|
|
5782
|
+
`, { mode: 384 });
|
|
5783
|
+
const imported = readCustomTemplateFile(destination);
|
|
5784
|
+
return { template: structuredClone(imported.summary), path: destination, replaced };
|
|
5785
|
+
}
|
|
5786
|
+
function validateLoopTemplateRegistry(opts = {}) {
|
|
5787
|
+
return {
|
|
5788
|
+
ok: true,
|
|
5789
|
+
templates: listLoopTemplates(opts),
|
|
5790
|
+
customDir: customLoopTemplatesDir()
|
|
5791
|
+
};
|
|
5792
|
+
}
|
|
5793
|
+
function listLoopTemplates(opts = {}) {
|
|
5794
|
+
const source = opts.source ?? "all";
|
|
5795
|
+
const templates = [];
|
|
5796
|
+
if (source !== "custom")
|
|
5797
|
+
templates.push(...builtinLoopTemplates());
|
|
5798
|
+
if (source !== "builtin")
|
|
5799
|
+
templates.push(...loadCustomLoopTemplates().map((entry) => structuredClone(entry.summary)));
|
|
5800
|
+
return templates;
|
|
5801
|
+
}
|
|
5802
|
+
function getLoopTemplate(id, opts = {}) {
|
|
5803
|
+
const source = opts.source ?? "all";
|
|
5804
|
+
if (source !== "custom") {
|
|
5805
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
5806
|
+
if (builtin)
|
|
5807
|
+
return builtin;
|
|
5808
|
+
if (source === "builtin")
|
|
5809
|
+
return;
|
|
5810
|
+
}
|
|
5811
|
+
return getCustomLoopTemplate(id)?.summary;
|
|
5463
5812
|
}
|
|
5464
5813
|
function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
5465
5814
|
if (!input.taskId?.trim())
|
|
@@ -5804,7 +6153,7 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
|
5804
6153
|
]
|
|
5805
6154
|
};
|
|
5806
6155
|
}
|
|
5807
|
-
function
|
|
6156
|
+
function renderBuiltinLoopTemplate(id, values) {
|
|
5808
6157
|
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
5809
6158
|
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
5810
6159
|
}
|
|
@@ -5918,12 +6267,26 @@ function booleanVar(value) {
|
|
|
5918
6267
|
function accountPoolVar(value, tool) {
|
|
5919
6268
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
5920
6269
|
}
|
|
6270
|
+
function renderLoopTemplate(id, values, opts = {}) {
|
|
6271
|
+
const source = opts.source ?? "all";
|
|
6272
|
+
if (source !== "custom") {
|
|
6273
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
6274
|
+
if (builtin)
|
|
6275
|
+
return renderBuiltinLoopTemplate(builtin.id, values);
|
|
6276
|
+
if (source === "builtin")
|
|
6277
|
+
throw new Error(`unknown built-in template: ${id}`);
|
|
6278
|
+
}
|
|
6279
|
+
const custom = getCustomLoopTemplate(id);
|
|
6280
|
+
if (custom)
|
|
6281
|
+
return renderCustomLoopTemplate(custom, values);
|
|
6282
|
+
throw new Error(`unknown template: ${id}`);
|
|
6283
|
+
}
|
|
5921
6284
|
// src/lib/doctor.ts
|
|
5922
6285
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
5923
6286
|
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
5924
6287
|
|
|
5925
6288
|
// src/daemon/control.ts
|
|
5926
|
-
import { existsSync as existsSync4, mkdirSync as
|
|
6289
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
5927
6290
|
import { hostname } from "os";
|
|
5928
6291
|
import { dirname as dirname2 } from "path";
|
|
5929
6292
|
|
|
@@ -5954,15 +6317,15 @@ function readPid(path = pidFilePath()) {
|
|
|
5954
6317
|
if (!existsSync4(path))
|
|
5955
6318
|
return;
|
|
5956
6319
|
try {
|
|
5957
|
-
const pid = Number(
|
|
6320
|
+
const pid = Number(readFileSync2(path, "utf8").trim());
|
|
5958
6321
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
5959
6322
|
} catch {
|
|
5960
6323
|
return;
|
|
5961
6324
|
}
|
|
5962
6325
|
}
|
|
5963
6326
|
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
5964
|
-
|
|
5965
|
-
|
|
6327
|
+
mkdirSync5(dirname2(path), { recursive: true, mode: 448 });
|
|
6328
|
+
writeFileSync3(path, String(pid));
|
|
5966
6329
|
}
|
|
5967
6330
|
function removePid(path = pidFilePath()) {
|
|
5968
6331
|
rmSync2(path, { force: true });
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSummary } from "../types.js";
|
|
1
|
+
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary } from "../types.js";
|
|
2
2
|
export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
3
3
|
export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
4
4
|
export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
@@ -97,9 +97,29 @@ export interface BoundedAgentWorkflowTemplateInput {
|
|
|
97
97
|
worktreeBranchPrefix?: string;
|
|
98
98
|
timeoutMs?: number;
|
|
99
99
|
}
|
|
100
|
-
export
|
|
101
|
-
export
|
|
100
|
+
export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
|
|
101
|
+
export interface ListLoopTemplatesOptions {
|
|
102
|
+
source?: LoopTemplateSourceFilter;
|
|
103
|
+
}
|
|
104
|
+
export interface CustomLoopTemplateImportOptions {
|
|
105
|
+
replace?: boolean;
|
|
106
|
+
}
|
|
107
|
+
export interface CustomLoopTemplateImportResult {
|
|
108
|
+
template: LoopTemplateSummary;
|
|
109
|
+
path: string;
|
|
110
|
+
replaced: boolean;
|
|
111
|
+
}
|
|
112
|
+
export declare function loopTemplatesDir(): string;
|
|
113
|
+
export declare function validateCustomLoopTemplateFile(file: string): LoopTemplateSummary;
|
|
114
|
+
export declare function importCustomLoopTemplate(file: string, opts?: CustomLoopTemplateImportOptions): CustomLoopTemplateImportResult;
|
|
115
|
+
export declare function validateLoopTemplateRegistry(opts?: ListLoopTemplatesOptions): {
|
|
116
|
+
ok: true;
|
|
117
|
+
templates: LoopTemplateSummary[];
|
|
118
|
+
customDir: string;
|
|
119
|
+
};
|
|
120
|
+
export declare function listLoopTemplates(opts?: ListLoopTemplatesOptions): LoopTemplateSummary[];
|
|
121
|
+
export declare function getLoopTemplate(id: string, opts?: ListLoopTemplatesOptions): LoopTemplateSummary | undefined;
|
|
102
122
|
export declare function renderTodosTaskWorkerVerifierWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
|
|
103
123
|
export declare function renderEventWorkerVerifierWorkflow(input: EventWorkflowTemplateInput): CreateWorkflowInput;
|
|
104
124
|
export declare function renderBoundedAgentWorkerVerifierWorkflow(input: BoundedAgentWorkflowTemplateInput): CreateWorkflowInput;
|
|
105
|
-
export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined
|
|
125
|
+
export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>, opts?: ListLoopTemplatesOptions): CreateWorkflowInput;
|
package/dist/types.d.ts
CHANGED
|
@@ -252,11 +252,14 @@ export interface CreateWorkflowInput {
|
|
|
252
252
|
version?: number;
|
|
253
253
|
}
|
|
254
254
|
export type LoopTemplateKind = "workflow" | "loop";
|
|
255
|
+
export type LoopTemplateSource = "builtin" | "custom";
|
|
256
|
+
export type LoopTemplateVariableType = "string" | "number" | "boolean" | "json" | "string[]";
|
|
255
257
|
export interface LoopTemplateVariable {
|
|
256
258
|
name: string;
|
|
257
259
|
description?: string;
|
|
258
260
|
required?: boolean;
|
|
259
261
|
default?: string;
|
|
262
|
+
type?: LoopTemplateVariableType;
|
|
260
263
|
}
|
|
261
264
|
export interface LoopTemplateSummary {
|
|
262
265
|
id: string;
|
|
@@ -264,6 +267,8 @@ export interface LoopTemplateSummary {
|
|
|
264
267
|
description: string;
|
|
265
268
|
kind: LoopTemplateKind;
|
|
266
269
|
variables: LoopTemplateVariable[];
|
|
270
|
+
source?: LoopTemplateSource;
|
|
271
|
+
sourcePath?: string;
|
|
267
272
|
}
|
|
268
273
|
export interface WorkflowRun {
|
|
269
274
|
id: string;
|
package/docs/USAGE.md
CHANGED
|
@@ -254,6 +254,67 @@ loops templates render deterministic-check-create-task \
|
|
|
254
254
|
--var checkCommand='your deterministic check and todos upsert command'
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
+
Custom reusable workflow templates live under the OpenLoops app data directory:
|
|
258
|
+
`~/.hasna/loops/templates` by default, or `$LOOPS_DATA_DIR/templates` when
|
|
259
|
+
`LOOPS_DATA_DIR` is set. Store templates as declarative JSON files; listing,
|
|
260
|
+
showing, and rendering templates never executes workflow steps or mutates the
|
|
261
|
+
registry.
|
|
262
|
+
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"id": "custom-report",
|
|
266
|
+
"name": "Custom Report",
|
|
267
|
+
"description": "Run a custom report workflow from the local template registry.",
|
|
268
|
+
"kind": "workflow",
|
|
269
|
+
"variables": [
|
|
270
|
+
{ "name": "objective", "required": true, "description": "Report objective." },
|
|
271
|
+
{ "name": "projectPath", "required": true, "description": "Working directory." },
|
|
272
|
+
{ "name": "timeoutMs", "default": "300000", "type": "number" }
|
|
273
|
+
],
|
|
274
|
+
"workflow": {
|
|
275
|
+
"name": "custom-report-${objective}",
|
|
276
|
+
"steps": [
|
|
277
|
+
{
|
|
278
|
+
"id": "worker",
|
|
279
|
+
"target": {
|
|
280
|
+
"type": "agent",
|
|
281
|
+
"provider": "codewith",
|
|
282
|
+
"prompt": "/goal ${objective}\nProduce the requested report only.",
|
|
283
|
+
"cwd": "${projectPath}",
|
|
284
|
+
"configIsolation": "safe",
|
|
285
|
+
"permissionMode": "bypass",
|
|
286
|
+
"sandbox": "workspace-write",
|
|
287
|
+
"timeoutMs": "${timeoutMs}"
|
|
288
|
+
},
|
|
289
|
+
"timeoutMs": "${timeoutMs}"
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
loops templates validate ./custom-report.json
|
|
298
|
+
loops templates import ./custom-report.json
|
|
299
|
+
loops templates list --source custom
|
|
300
|
+
loops templates show custom-report
|
|
301
|
+
loops templates render custom-report \
|
|
302
|
+
--var objective="Check docs drift" \
|
|
303
|
+
--var projectPath=/path/to/repo
|
|
304
|
+
loops templates create-workflow custom-report \
|
|
305
|
+
--var objective="Check docs drift" \
|
|
306
|
+
--var projectPath=/path/to/repo
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Use `--source builtin`, `--source custom`, or `--source all` on
|
|
310
|
+
`list`, `show`, `render`, and `create-workflow` when automation needs an
|
|
311
|
+
explicit source. Custom template ids and names cannot override built-ins.
|
|
312
|
+
Custom templates fail closed for `danger-full-access`, dangerous passthrough
|
|
313
|
+
arguments, and implicit Codewith/Codex full-access defaults. If a custom
|
|
314
|
+
Codewith/Codex template uses `permissionMode: "bypass"`, it must also set
|
|
315
|
+
`sandbox` to `workspace-write` or `read-only`. Use built-in templates with
|
|
316
|
+
explicit break-glass handling for emergency workflows that need full access.
|
|
317
|
+
|
|
257
318
|
Repo-mutating task/event routes should set `worktreeMode=required` so the
|
|
258
319
|
workflow fails fast instead of falling back to the main checkout. When
|
|
259
320
|
`projectPath` is an existing git repository, OpenLoops inserts a
|
|
@@ -554,6 +615,20 @@ production loop. Route commands store a small cursor in
|
|
|
554
615
|
through all findings over repeated scheduled runs instead of reprocessing only
|
|
555
616
|
the first batch.
|
|
556
617
|
|
|
618
|
+
For a deliberate operator rename, use the first-class rename command instead of
|
|
619
|
+
turning a one-off naming preference into hygiene policy:
|
|
620
|
+
|
|
621
|
+
```bash
|
|
622
|
+
loops rename machine-todos-drain-oss-repos-strict-5m machine-todos-drain-oss-repos
|
|
623
|
+
loops --json rename <loop-id> machine-ops-loop-health-route-tasks
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
`rename` preserves the loop id, schedule, run history, archive state, and target
|
|
627
|
+
configuration. It rejects empty or duplicate names and writes a SQLite backup
|
|
628
|
+
under `<LOOPS_DATA_DIR>/backups` before mutating the loop row. Human-facing
|
|
629
|
+
names should describe scope and responsibility; cadence belongs in schedule
|
|
630
|
+
metadata and operator tables.
|
|
631
|
+
|
|
557
632
|
Archive loops when retiring old automation but preserving history:
|
|
558
633
|
|
|
559
634
|
```bash
|
package/package.json
CHANGED