@hasna/loops 0.3.49 → 0.3.50
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 +74 -0
- package/dist/cli/index.js +407 -28
- package/dist/daemon/index.js +1 -1
- package/dist/index.js +328 -10
- package/dist/lib/templates.d.ts +24 -4
- package/dist/types.d.ts +5 -0
- package/docs/USAGE.md +73 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -251,6 +251,65 @@ 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`; use built-in templates
|
|
310
|
+
with explicit break-glass handling for emergency workflows that need that
|
|
311
|
+
sandbox.
|
|
312
|
+
|
|
254
313
|
For event-driven task automation, `loops events handle todos-task` reads a
|
|
255
314
|
Hasna event envelope from stdin or `HASNA_EVENT_JSON`, records a
|
|
256
315
|
`WorkflowInvocation`, upserts an admission work item, and admits that work item
|
|
@@ -421,6 +480,7 @@ loops runs <id-or-name>
|
|
|
421
480
|
loops pause <id-or-name>
|
|
422
481
|
loops resume <id-or-name>
|
|
423
482
|
loops stop <id-or-name>
|
|
483
|
+
loops rename <id-or-name> <new-name>
|
|
424
484
|
loops archive <id-or-name>
|
|
425
485
|
loops unarchive <id-or-name>
|
|
426
486
|
loops remove <id-or-name>
|
|
@@ -468,6 +528,20 @@ package-managed cursor under `<LOOPS_DATA_DIR>/route-cursors.json` so bounded
|
|
|
468
528
|
`--max-actions` runs advance through all findings over repeated scheduled runs
|
|
469
529
|
instead of reprocessing only the first batch.
|
|
470
530
|
|
|
531
|
+
Use `loops rename` for deliberate operator naming changes that should not be
|
|
532
|
+
encoded as automatic hygiene policy:
|
|
533
|
+
|
|
534
|
+
```bash
|
|
535
|
+
loops rename machine-todos-drain-oss-repos-strict-5m machine-todos-drain-oss-repos
|
|
536
|
+
loops --json rename <loop-id> machine-ops-loop-health-route-tasks
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
`rename` preserves the loop id, schedule, run history, archive state, and target
|
|
540
|
+
configuration. It rejects empty or duplicate names and writes a SQLite backup
|
|
541
|
+
under `<LOOPS_DATA_DIR>/backups` before mutating the loop row. Keep cadence in
|
|
542
|
+
schedule metadata and operator tables rather than in the loop name when the name
|
|
543
|
+
is meant for humans.
|
|
544
|
+
|
|
471
545
|
Archive loops when retiring old automation but preserving history:
|
|
472
546
|
|
|
473
547
|
```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.50",
|
|
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,11 @@ 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_]*)\}$/;
|
|
6202
6207
|
function compactJson(value) {
|
|
6203
6208
|
return JSON.stringify(value);
|
|
6204
6209
|
}
|
|
@@ -6476,11 +6481,310 @@ function workflowStepsWithWorktree(plan, steps) {
|
|
|
6476
6481
|
...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
|
|
6477
6482
|
];
|
|
6478
6483
|
}
|
|
6479
|
-
function
|
|
6480
|
-
|
|
6484
|
+
function assertRecord(value, label) {
|
|
6485
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
6486
|
+
throw new Error(`${label} must be an object`);
|
|
6487
|
+
}
|
|
6488
|
+
function assertTemplateString(value, label) {
|
|
6489
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
6490
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
6491
|
+
return value.trim();
|
|
6492
|
+
}
|
|
6493
|
+
function assertTemplateKind(value, label) {
|
|
6494
|
+
const kind = assertTemplateString(value, label);
|
|
6495
|
+
if (kind !== "workflow")
|
|
6496
|
+
throw new Error(`${label} must be workflow; custom loop templates are not supported yet`);
|
|
6497
|
+
return kind;
|
|
6498
|
+
}
|
|
6499
|
+
function customLoopTemplatesDir() {
|
|
6500
|
+
return join5(dataDir(), "templates");
|
|
6501
|
+
}
|
|
6502
|
+
function ensureCustomLoopTemplatesDir() {
|
|
6503
|
+
const dir = customLoopTemplatesDir();
|
|
6504
|
+
mkdirSync6(dir, { recursive: true, mode: 448 });
|
|
6505
|
+
return dir;
|
|
6506
|
+
}
|
|
6507
|
+
function loopTemplatesDir() {
|
|
6508
|
+
return customLoopTemplatesDir();
|
|
6509
|
+
}
|
|
6510
|
+
function builtinLoopTemplates() {
|
|
6511
|
+
return TEMPLATE_SUMMARIES.map((template) => ({ ...structuredClone(template), source: "builtin" }));
|
|
6512
|
+
}
|
|
6513
|
+
function getBuiltinLoopTemplate(id) {
|
|
6514
|
+
return builtinLoopTemplates().find((template) => template.id === id || template.name === id);
|
|
6515
|
+
}
|
|
6516
|
+
function builtinTemplateKeys() {
|
|
6517
|
+
const keys = new Set;
|
|
6518
|
+
for (const template of TEMPLATE_SUMMARIES) {
|
|
6519
|
+
keys.add(template.id);
|
|
6520
|
+
keys.add(template.name);
|
|
6521
|
+
}
|
|
6522
|
+
return keys;
|
|
6523
|
+
}
|
|
6524
|
+
function validateCustomTemplateId(id, label) {
|
|
6525
|
+
if (!CUSTOM_TEMPLATE_ID_PATTERN.test(id)) {
|
|
6526
|
+
throw new Error(`${label} must match ${CUSTOM_TEMPLATE_ID_PATTERN.source}`);
|
|
6527
|
+
}
|
|
6528
|
+
}
|
|
6529
|
+
function validateCustomTemplateVariables(value, label) {
|
|
6530
|
+
if (value === undefined)
|
|
6531
|
+
return [];
|
|
6532
|
+
if (!Array.isArray(value))
|
|
6533
|
+
throw new Error(`${label} must be an array`);
|
|
6534
|
+
const seen = new Set;
|
|
6535
|
+
return value.map((entry, index) => {
|
|
6536
|
+
const entryLabel = `${label}[${index}]`;
|
|
6537
|
+
assertRecord(entry, entryLabel);
|
|
6538
|
+
const name = assertTemplateString(entry.name, `${entryLabel}.name`);
|
|
6539
|
+
if (!CUSTOM_TEMPLATE_VARIABLE_PATTERN.test(name)) {
|
|
6540
|
+
throw new Error(`${entryLabel}.name must match ${CUSTOM_TEMPLATE_VARIABLE_PATTERN.source}`);
|
|
6541
|
+
}
|
|
6542
|
+
if (seen.has(name))
|
|
6543
|
+
throw new Error(`duplicate custom template variable: ${name}`);
|
|
6544
|
+
seen.add(name);
|
|
6545
|
+
const description = entry.description === undefined ? undefined : assertTemplateString(entry.description, `${entryLabel}.description`);
|
|
6546
|
+
const defaultValue = entry.default === undefined ? undefined : assertTemplateString(entry.default, `${entryLabel}.default`);
|
|
6547
|
+
const type = entry.type === undefined ? undefined : assertTemplateString(entry.type, `${entryLabel}.type`);
|
|
6548
|
+
if (type && !CUSTOM_TEMPLATE_VARIABLE_TYPES.has(type)) {
|
|
6549
|
+
throw new Error(`${entryLabel}.type must be one of ${[...CUSTOM_TEMPLATE_VARIABLE_TYPES].join(", ")}`);
|
|
6550
|
+
}
|
|
6551
|
+
if (defaultValue === "danger-full-access") {
|
|
6552
|
+
throw new Error(`${entryLabel}.default cannot be danger-full-access in a custom template`);
|
|
6553
|
+
}
|
|
6554
|
+
return {
|
|
6555
|
+
name,
|
|
6556
|
+
description,
|
|
6557
|
+
required: entry.required === undefined ? undefined : Boolean(entry.required),
|
|
6558
|
+
default: defaultValue,
|
|
6559
|
+
type
|
|
6560
|
+
};
|
|
6561
|
+
});
|
|
6562
|
+
}
|
|
6563
|
+
function assertNoDangerFullAccess(value, label) {
|
|
6564
|
+
if (!value || typeof value !== "object")
|
|
6565
|
+
return;
|
|
6566
|
+
if (Array.isArray(value)) {
|
|
6567
|
+
value.forEach((entry, index) => assertNoDangerFullAccess(entry, `${label}[${index}]`));
|
|
6568
|
+
return;
|
|
6569
|
+
}
|
|
6570
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
6571
|
+
if (key === "sandbox" && entry === "danger-full-access") {
|
|
6572
|
+
throw new Error(`${label}.${key} uses danger-full-access; custom templates must not request danger-full-access`);
|
|
6573
|
+
}
|
|
6574
|
+
assertNoDangerFullAccess(entry, `${label}.${key}`);
|
|
6575
|
+
}
|
|
6576
|
+
}
|
|
6577
|
+
function customTemplateDefinitionFromJson(value, sourcePath) {
|
|
6578
|
+
assertRecord(value, sourcePath);
|
|
6579
|
+
const id = assertTemplateString(value.id, `${sourcePath}.id`);
|
|
6580
|
+
validateCustomTemplateId(id, `${sourcePath}.id`);
|
|
6581
|
+
const name = assertTemplateString(value.name, `${sourcePath}.name`);
|
|
6582
|
+
const description = assertTemplateString(value.description, `${sourcePath}.description`);
|
|
6583
|
+
const kind = assertTemplateKind(value.kind ?? "workflow", `${sourcePath}.kind`);
|
|
6584
|
+
const variables = validateCustomTemplateVariables(value.variables, `${sourcePath}.variables`);
|
|
6585
|
+
if (value.workflow === undefined)
|
|
6586
|
+
throw new Error(`${sourcePath}.workflow is required`);
|
|
6587
|
+
assertRecord(value.workflow, `${sourcePath}.workflow`);
|
|
6588
|
+
assertNoDangerFullAccess(value.workflow, `${sourcePath}.workflow`);
|
|
6589
|
+
return { id, name, description, kind, variables, workflow: value.workflow };
|
|
6590
|
+
}
|
|
6591
|
+
function customTemplateSummary(definition, sourcePath) {
|
|
6592
|
+
return {
|
|
6593
|
+
id: definition.id,
|
|
6594
|
+
name: definition.name,
|
|
6595
|
+
description: definition.description,
|
|
6596
|
+
kind: definition.kind,
|
|
6597
|
+
variables: structuredClone(definition.variables),
|
|
6598
|
+
source: "custom",
|
|
6599
|
+
sourcePath
|
|
6600
|
+
};
|
|
6601
|
+
}
|
|
6602
|
+
function readCustomTemplateFile(file) {
|
|
6603
|
+
let parsed;
|
|
6604
|
+
try {
|
|
6605
|
+
parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
6606
|
+
} catch (error) {
|
|
6607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6608
|
+
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
6609
|
+
}
|
|
6610
|
+
const definition = customTemplateDefinitionFromJson(parsed, file);
|
|
6611
|
+
return { definition, summary: customTemplateSummary(definition, file), path: file };
|
|
6612
|
+
}
|
|
6613
|
+
function assertNoTemplateCollisions(entries) {
|
|
6614
|
+
const builtinKeys = builtinTemplateKeys();
|
|
6615
|
+
const seen = new Map;
|
|
6616
|
+
for (const entry of entries) {
|
|
6617
|
+
for (const key of [entry.definition.id, entry.definition.name]) {
|
|
6618
|
+
if (builtinKeys.has(key)) {
|
|
6619
|
+
throw new Error(`custom template ${entry.definition.id} collides with built-in template key ${key}; choose a different id or name`);
|
|
6620
|
+
}
|
|
6621
|
+
const existing = seen.get(key);
|
|
6622
|
+
if (existing) {
|
|
6623
|
+
throw new Error(`custom template ${entry.definition.id} collides with ${existing} on key ${key}`);
|
|
6624
|
+
}
|
|
6625
|
+
seen.set(key, entry.definition.id);
|
|
6626
|
+
}
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6629
|
+
function loadCustomLoopTemplates() {
|
|
6630
|
+
const dir = customLoopTemplatesDir();
|
|
6631
|
+
if (!existsSync4(dir))
|
|
6632
|
+
return [];
|
|
6633
|
+
const entries = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
6634
|
+
const file = join5(dir, entry.name);
|
|
6635
|
+
if (entry.isSymbolicLink())
|
|
6636
|
+
throw new Error(`refusing symlinked custom template file: ${file}`);
|
|
6637
|
+
if (!entry.isFile())
|
|
6638
|
+
throw new Error(`custom template registry entry is not a regular file: ${file}`);
|
|
6639
|
+
return readCustomTemplateFile(file);
|
|
6640
|
+
});
|
|
6641
|
+
assertNoTemplateCollisions(entries);
|
|
6642
|
+
return entries;
|
|
6643
|
+
}
|
|
6644
|
+
function getCustomLoopTemplate(id) {
|
|
6645
|
+
return loadCustomLoopTemplates().find((template) => template.definition.id === id || template.definition.name === id);
|
|
6646
|
+
}
|
|
6647
|
+
function coerceCustomTemplateValue(raw, type, label) {
|
|
6648
|
+
const normalizedType = type ?? "string";
|
|
6649
|
+
if (normalizedType === "string")
|
|
6650
|
+
return String(raw);
|
|
6651
|
+
if (normalizedType === "number") {
|
|
6652
|
+
const value = typeof raw === "number" ? raw : Number(String(raw));
|
|
6653
|
+
if (!Number.isFinite(value))
|
|
6654
|
+
throw new Error(`${label} must be a finite number`);
|
|
6655
|
+
return value;
|
|
6656
|
+
}
|
|
6657
|
+
if (normalizedType === "boolean") {
|
|
6658
|
+
if (typeof raw === "boolean")
|
|
6659
|
+
return raw;
|
|
6660
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
6661
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
6662
|
+
return true;
|
|
6663
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
6664
|
+
return false;
|
|
6665
|
+
throw new Error(`${label} must be a boolean`);
|
|
6666
|
+
}
|
|
6667
|
+
if (normalizedType === "json") {
|
|
6668
|
+
if (typeof raw !== "string")
|
|
6669
|
+
return raw;
|
|
6670
|
+
try {
|
|
6671
|
+
return JSON.parse(raw);
|
|
6672
|
+
} catch (error) {
|
|
6673
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6674
|
+
throw new Error(`${label} must be valid JSON: ${message}`);
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
if (normalizedType === "string[]") {
|
|
6678
|
+
if (Array.isArray(raw))
|
|
6679
|
+
return raw.map((entry) => String(entry));
|
|
6680
|
+
return String(raw).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
6681
|
+
}
|
|
6682
|
+
return String(raw);
|
|
6481
6683
|
}
|
|
6482
|
-
function
|
|
6483
|
-
|
|
6684
|
+
function customTemplateValues(definition, values) {
|
|
6685
|
+
const variablesByName = new Map(definition.variables.map((variable) => [variable.name, variable]));
|
|
6686
|
+
for (const [name, value] of Object.entries(values)) {
|
|
6687
|
+
if (value !== undefined && !variablesByName.has(name)) {
|
|
6688
|
+
throw new Error(`unknown variable for custom template ${definition.id}: ${name}`);
|
|
6689
|
+
}
|
|
6690
|
+
}
|
|
6691
|
+
const rendered = {};
|
|
6692
|
+
for (const variable of definition.variables) {
|
|
6693
|
+
const raw = values[variable.name] ?? variable.default;
|
|
6694
|
+
if (raw === undefined || raw === "") {
|
|
6695
|
+
if (variable.required)
|
|
6696
|
+
throw new Error(`${variable.name} is required`);
|
|
6697
|
+
continue;
|
|
6698
|
+
}
|
|
6699
|
+
rendered[variable.name] = coerceCustomTemplateValue(raw, variable.type, variable.name);
|
|
6700
|
+
}
|
|
6701
|
+
return rendered;
|
|
6702
|
+
}
|
|
6703
|
+
function customTemplateValueForPlaceholder(values, name, templateId) {
|
|
6704
|
+
if (!(name in values))
|
|
6705
|
+
throw new Error(`custom template ${templateId} requires variable ${name}`);
|
|
6706
|
+
return values[name];
|
|
6707
|
+
}
|
|
6708
|
+
function stringifyCustomTemplateValue(value, name) {
|
|
6709
|
+
if (typeof value === "string")
|
|
6710
|
+
return value;
|
|
6711
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
6712
|
+
return String(value);
|
|
6713
|
+
if (value === null)
|
|
6714
|
+
return "null";
|
|
6715
|
+
if (Array.isArray(value) || typeof value === "object")
|
|
6716
|
+
return JSON.stringify(value);
|
|
6717
|
+
throw new Error(`custom template variable ${name} cannot be rendered as a string`);
|
|
6718
|
+
}
|
|
6719
|
+
function renderCustomTemplateNode(value, values, templateId) {
|
|
6720
|
+
if (typeof value === "string") {
|
|
6721
|
+
const exact = CUSTOM_TEMPLATE_EXACT_PLACEHOLDER.exec(value);
|
|
6722
|
+
if (exact)
|
|
6723
|
+
return customTemplateValueForPlaceholder(values, exact[1], templateId);
|
|
6724
|
+
return value.replace(CUSTOM_TEMPLATE_PLACEHOLDER, (_match, name) => stringifyCustomTemplateValue(customTemplateValueForPlaceholder(values, name, templateId), name));
|
|
6725
|
+
}
|
|
6726
|
+
if (Array.isArray(value))
|
|
6727
|
+
return value.map((entry) => renderCustomTemplateNode(entry, values, templateId));
|
|
6728
|
+
if (!value || typeof value !== "object")
|
|
6729
|
+
return value;
|
|
6730
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderCustomTemplateNode(entry, values, templateId)]));
|
|
6731
|
+
}
|
|
6732
|
+
function renderCustomLoopTemplate(entry, values) {
|
|
6733
|
+
const renderedValues = customTemplateValues(entry.definition, values);
|
|
6734
|
+
const rendered = renderCustomTemplateNode(entry.definition.workflow, renderedValues, entry.definition.id);
|
|
6735
|
+
assertNoDangerFullAccess(rendered, `custom template ${entry.definition.id}.workflow`);
|
|
6736
|
+
return workflowBodyFromJson(rendered);
|
|
6737
|
+
}
|
|
6738
|
+
function validateCustomLoopTemplateFile(file) {
|
|
6739
|
+
const entry = readCustomTemplateFile(resolve(file));
|
|
6740
|
+
assertNoTemplateCollisions([entry]);
|
|
6741
|
+
return structuredClone(entry.summary);
|
|
6742
|
+
}
|
|
6743
|
+
function importCustomLoopTemplate(file, opts = {}) {
|
|
6744
|
+
const source = resolve(file);
|
|
6745
|
+
const entry = readCustomTemplateFile(source);
|
|
6746
|
+
assertNoTemplateCollisions([entry]);
|
|
6747
|
+
const dir = ensureCustomLoopTemplatesDir();
|
|
6748
|
+
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
6749
|
+
const replaced = existsSync4(destination);
|
|
6750
|
+
if (replaced) {
|
|
6751
|
+
const stat = lstatSync(destination);
|
|
6752
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
6753
|
+
throw new Error(`refusing to replace non-regular custom template file: ${destination}`);
|
|
6754
|
+
if (!opts.replace)
|
|
6755
|
+
throw new Error(`custom template already exists: ${entry.definition.id}; use --replace to overwrite it`);
|
|
6756
|
+
}
|
|
6757
|
+
writeFileSync4(destination, `${JSON.stringify(entry.definition, null, 2)}
|
|
6758
|
+
`, { mode: 384 });
|
|
6759
|
+
const imported = readCustomTemplateFile(destination);
|
|
6760
|
+
return { template: structuredClone(imported.summary), path: destination, replaced };
|
|
6761
|
+
}
|
|
6762
|
+
function validateLoopTemplateRegistry(opts = {}) {
|
|
6763
|
+
return {
|
|
6764
|
+
ok: true,
|
|
6765
|
+
templates: listLoopTemplates(opts),
|
|
6766
|
+
customDir: customLoopTemplatesDir()
|
|
6767
|
+
};
|
|
6768
|
+
}
|
|
6769
|
+
function listLoopTemplates(opts = {}) {
|
|
6770
|
+
const source = opts.source ?? "all";
|
|
6771
|
+
const templates = [];
|
|
6772
|
+
if (source !== "custom")
|
|
6773
|
+
templates.push(...builtinLoopTemplates());
|
|
6774
|
+
if (source !== "builtin")
|
|
6775
|
+
templates.push(...loadCustomLoopTemplates().map((entry) => structuredClone(entry.summary)));
|
|
6776
|
+
return templates;
|
|
6777
|
+
}
|
|
6778
|
+
function getLoopTemplate(id, opts = {}) {
|
|
6779
|
+
const source = opts.source ?? "all";
|
|
6780
|
+
if (source !== "custom") {
|
|
6781
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
6782
|
+
if (builtin)
|
|
6783
|
+
return builtin;
|
|
6784
|
+
if (source === "builtin")
|
|
6785
|
+
return;
|
|
6786
|
+
}
|
|
6787
|
+
return getCustomLoopTemplate(id)?.summary;
|
|
6484
6788
|
}
|
|
6485
6789
|
function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
6486
6790
|
if (!input.taskId?.trim())
|
|
@@ -6825,7 +7129,7 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
|
6825
7129
|
]
|
|
6826
7130
|
};
|
|
6827
7131
|
}
|
|
6828
|
-
function
|
|
7132
|
+
function renderBuiltinLoopTemplate(id, values) {
|
|
6829
7133
|
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
6830
7134
|
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
6831
7135
|
}
|
|
@@ -6939,6 +7243,20 @@ function booleanVar(value) {
|
|
|
6939
7243
|
function accountPoolVar(value, tool) {
|
|
6940
7244
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
6941
7245
|
}
|
|
7246
|
+
function renderLoopTemplate(id, values, opts = {}) {
|
|
7247
|
+
const source = opts.source ?? "all";
|
|
7248
|
+
if (source !== "custom") {
|
|
7249
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
7250
|
+
if (builtin)
|
|
7251
|
+
return renderBuiltinLoopTemplate(builtin.id, values);
|
|
7252
|
+
if (source === "builtin")
|
|
7253
|
+
throw new Error(`unknown built-in template: ${id}`);
|
|
7254
|
+
}
|
|
7255
|
+
const custom = getCustomLoopTemplate(id);
|
|
7256
|
+
if (custom)
|
|
7257
|
+
return renderCustomLoopTemplate(custom, values);
|
|
7258
|
+
throw new Error(`unknown template: ${id}`);
|
|
7259
|
+
}
|
|
6942
7260
|
|
|
6943
7261
|
// src/cli/index.ts
|
|
6944
7262
|
var program = new Command;
|
|
@@ -7230,7 +7548,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
|
7230
7548
|
return {
|
|
7231
7549
|
ok: result.status === 0,
|
|
7232
7550
|
status: result.status,
|
|
7233
|
-
stdout:
|
|
7551
|
+
stdout: readFileSync3(stdoutPath, "utf8"),
|
|
7234
7552
|
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
|
|
7235
7553
|
error: result.error ? String(result.error.message || result.error) : ""
|
|
7236
7554
|
};
|
|
@@ -7252,11 +7570,11 @@ function ensureTodosTaskList(project, slug, name, description) {
|
|
|
7252
7570
|
function backupLoopsDatabase(reason) {
|
|
7253
7571
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
7254
7572
|
const backupDir = join6(dataDir(), "backups");
|
|
7255
|
-
|
|
7573
|
+
mkdirSync7(backupDir, { recursive: true, mode: 448 });
|
|
7256
7574
|
const backupPath = join6(backupDir, `loops.db.bak-${reason}-${stamp}`);
|
|
7257
7575
|
const db = new Database2(dbPath(), { readonly: true });
|
|
7258
7576
|
try {
|
|
7259
|
-
|
|
7577
|
+
writeFileSync5(backupPath, db.serialize(), { mode: 384 });
|
|
7260
7578
|
} finally {
|
|
7261
7579
|
db.close();
|
|
7262
7580
|
}
|
|
@@ -7274,7 +7592,7 @@ function readRouteCursors() {
|
|
|
7274
7592
|
if (!existsSync5(path))
|
|
7275
7593
|
return {};
|
|
7276
7594
|
try {
|
|
7277
|
-
const parsed = JSON.parse(
|
|
7595
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
7278
7596
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
7279
7597
|
} catch {
|
|
7280
7598
|
return {};
|
|
@@ -7285,15 +7603,15 @@ function writeRouteCursor(key, lastFingerprint) {
|
|
|
7285
7603
|
return;
|
|
7286
7604
|
const cursors = readRouteCursors();
|
|
7287
7605
|
cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
|
|
7288
|
-
|
|
7606
|
+
writeFileSync5(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
|
|
7289
7607
|
}
|
|
7290
7608
|
function writeRouteEvidence(kind, value, evidenceDir) {
|
|
7291
7609
|
if (!evidenceDir)
|
|
7292
7610
|
return;
|
|
7293
|
-
|
|
7611
|
+
mkdirSync7(evidenceDir, { recursive: true, mode: 448 });
|
|
7294
7612
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
7295
7613
|
const evidencePath = join6(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
7296
|
-
|
|
7614
|
+
writeFileSync5(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
7297
7615
|
return evidencePath;
|
|
7298
7616
|
}
|
|
7299
7617
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -7686,7 +8004,7 @@ function routeThrottleDryRunPreview(args) {
|
|
|
7686
8004
|
};
|
|
7687
8005
|
}
|
|
7688
8006
|
async function readEventEnvelopeInput(opts = {}) {
|
|
7689
|
-
const raw = opts.eventJson ?? (opts.eventFile ?
|
|
8007
|
+
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync3(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
7690
8008
|
const event = JSON.parse(raw);
|
|
7691
8009
|
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
7692
8010
|
throw new Error("event JSON must be an object");
|
|
@@ -8599,9 +8917,20 @@ function formatTemplateVariable(template, name) {
|
|
|
8599
8917
|
const placeholder = variable?.default ? variable.default : `<${name}>`;
|
|
8600
8918
|
return ` --var ${name}=${placeholder}`;
|
|
8601
8919
|
}
|
|
8920
|
+
function templateSource(value) {
|
|
8921
|
+
const source = value ?? "all";
|
|
8922
|
+
if (source === "all" || source === "builtin" || source === "custom")
|
|
8923
|
+
return source;
|
|
8924
|
+
throw new Error("--source must be all, builtin, or custom");
|
|
8925
|
+
}
|
|
8926
|
+
function addTemplateSourceOption(command, defaultValue = "all") {
|
|
8927
|
+
return command.option("--source <source>", "template source: all, builtin, or custom", defaultValue);
|
|
8928
|
+
}
|
|
8602
8929
|
function printTemplateDetails(template) {
|
|
8603
8930
|
console.log(`${template.id} (${template.kind})`);
|
|
8604
8931
|
console.log(template.name);
|
|
8932
|
+
if (template.source)
|
|
8933
|
+
console.log(`source: ${template.source}${template.sourcePath ? ` (${template.sourcePath})` : ""}`);
|
|
8605
8934
|
console.log("");
|
|
8606
8935
|
console.log(template.description);
|
|
8607
8936
|
console.log("");
|
|
@@ -8645,18 +8974,31 @@ function printWorkflowListWarning(args) {
|
|
|
8645
8974
|
const next = args.limit && nextOffset < args.total ? ` next page: --limit ${args.limit} --offset ${nextOffset}` : "";
|
|
8646
8975
|
console.error(`showing ${args.offset + args.shown} of ${args.total} ${scope} workflows.${next}`);
|
|
8647
8976
|
}
|
|
8648
|
-
templates.command("list").alias("ls").description("list
|
|
8649
|
-
const values = listLoopTemplates();
|
|
8977
|
+
templates.command("list").alias("ls").description("list OpenLoops templates").option("--source <source>", "template source: all, builtin, or custom", "all").action((opts) => {
|
|
8978
|
+
const values = listLoopTemplates({ source: templateSource(opts.source) });
|
|
8650
8979
|
if (isJson())
|
|
8651
8980
|
print(values);
|
|
8652
8981
|
else {
|
|
8653
8982
|
for (const template of values) {
|
|
8654
|
-
console.log(`${template.id} ${template.kind} ${template.description}`);
|
|
8983
|
+
console.log(`${template.id} ${template.source ?? "builtin"} ${template.kind} ${template.description}`);
|
|
8655
8984
|
}
|
|
8656
8985
|
}
|
|
8657
8986
|
});
|
|
8658
|
-
templates.command("
|
|
8659
|
-
const
|
|
8987
|
+
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) => {
|
|
8988
|
+
const result = importCustomLoopTemplate(file, { replace: Boolean(opts.replace) });
|
|
8989
|
+
print(result, `${result.replaced ? "replaced" : "imported"} custom template ${result.template.id} -> ${result.path}`);
|
|
8990
|
+
});
|
|
8991
|
+
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) => {
|
|
8992
|
+
if (file) {
|
|
8993
|
+
const template = validateCustomLoopTemplateFile(file);
|
|
8994
|
+
print({ ok: true, template, customDir: loopTemplatesDir() }, `valid custom template ${template.id}`);
|
|
8995
|
+
return;
|
|
8996
|
+
}
|
|
8997
|
+
const result = validateLoopTemplateRegistry({ source: templateSource(opts.source) });
|
|
8998
|
+
print(result, `valid template registry (${result.templates.length} templates) customDir=${result.customDir}`);
|
|
8999
|
+
});
|
|
9000
|
+
addTemplateSourceOption(templates.command("show <id>").description("show a template")).action((id, opts) => {
|
|
9001
|
+
const template = getLoopTemplate(id, { source: templateSource(opts.source) });
|
|
8660
9002
|
if (!template)
|
|
8661
9003
|
throw new Error(`template not found: ${id}`);
|
|
8662
9004
|
if (isJson())
|
|
@@ -8664,14 +9006,14 @@ templates.command("show <id>").description("show a built-in template").action((i
|
|
|
8664
9006
|
else
|
|
8665
9007
|
printTemplateDetails(template);
|
|
8666
9008
|
});
|
|
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));
|
|
9009
|
+
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) => {
|
|
9010
|
+
const workflow = renderLoopTemplate(id, parseVars(opts.var), { source: templateSource(opts.source) });
|
|
8669
9011
|
print(workflow, JSON.stringify(workflow, null, 2));
|
|
8670
9012
|
});
|
|
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) => {
|
|
9013
|
+
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
9014
|
const store = new Store;
|
|
8673
9015
|
try {
|
|
8674
|
-
const body = renderLoopTemplate(id, parseVars(opts.var));
|
|
9016
|
+
const body = renderLoopTemplate(id, parseVars(opts.var), { source: templateSource(opts.source) });
|
|
8675
9017
|
const workflow = store.createWorkflow(body);
|
|
8676
9018
|
print(publicWorkflow(workflow), `created workflow ${workflow.id} (${workflow.name}) steps=${workflow.steps.length}`);
|
|
8677
9019
|
} finally {
|
|
@@ -8843,7 +9185,7 @@ machines.command("show <id>").description("resolve a machine assignment").action
|
|
|
8843
9185
|
print(resolveLoopMachine(id));
|
|
8844
9186
|
});
|
|
8845
9187
|
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(
|
|
9188
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync3(file, "utf8")), opts.name);
|
|
8847
9189
|
const workflow = workflowSpecForPreflight(body);
|
|
8848
9190
|
const preflight = opts.preflight ? preflightWorkflow(workflow) : undefined;
|
|
8849
9191
|
print({ valid: true, workflow: publicWorkflow(workflow), preflight }, `valid workflow ${workflow.name} steps=${workflow.steps.length}`);
|
|
@@ -8851,7 +9193,7 @@ workflows.command("validate <file>").description("validate a workflow JSON file
|
|
|
8851
9193
|
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
9194
|
const store = new Store;
|
|
8853
9195
|
try {
|
|
8854
|
-
const body = workflowBodyFromJson(JSON.parse(
|
|
9196
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync3(file, "utf8")), opts.name);
|
|
8855
9197
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(body, "creation-preflight"), { name: body.name, type: "workflow" }, {}) : undefined;
|
|
8856
9198
|
const workflow = store.createWorkflow(body);
|
|
8857
9199
|
if (preflight !== undefined)
|
|
@@ -9485,6 +9827,43 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
9485
9827
|
program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
|
|
9486
9828
|
program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
|
|
9487
9829
|
program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
|
|
9830
|
+
program.command("rename <idOrName> <newName>").description("rename a loop without changing its id, schedule, runs, or history").action((idOrName, newName) => {
|
|
9831
|
+
const store = new Store;
|
|
9832
|
+
try {
|
|
9833
|
+
const loop = store.requireLoop(idOrName);
|
|
9834
|
+
const oldName = loop.name;
|
|
9835
|
+
const trimmed = String(newName).trim();
|
|
9836
|
+
if (!trimmed)
|
|
9837
|
+
throw new Error("loop name must not be empty");
|
|
9838
|
+
const existing = store.findLoopByName(trimmed);
|
|
9839
|
+
if (existing && existing.id !== loop.id) {
|
|
9840
|
+
throw new Error(`loop name already exists: ${trimmed} (${existing.id})`);
|
|
9841
|
+
}
|
|
9842
|
+
if (trimmed === oldName) {
|
|
9843
|
+
print({
|
|
9844
|
+
changed: false,
|
|
9845
|
+
id: loop.id,
|
|
9846
|
+
oldName,
|
|
9847
|
+
newName: oldName,
|
|
9848
|
+
loop: publicLoop(loop)
|
|
9849
|
+
}, `${loop.id} unchanged (${oldName})`);
|
|
9850
|
+
return;
|
|
9851
|
+
}
|
|
9852
|
+
const backupPath = backupLoopsDatabase("rename");
|
|
9853
|
+
const renamed = store.renameLoop(loop.id, trimmed);
|
|
9854
|
+
print({
|
|
9855
|
+
changed: true,
|
|
9856
|
+
id: renamed.id,
|
|
9857
|
+
oldName,
|
|
9858
|
+
newName: renamed.name,
|
|
9859
|
+
backupPath,
|
|
9860
|
+
loop: publicLoop(renamed)
|
|
9861
|
+
}, `${renamed.id} renamed ${oldName} -> ${renamed.name}
|
|
9862
|
+
backup=${backupPath}`);
|
|
9863
|
+
} finally {
|
|
9864
|
+
store.close();
|
|
9865
|
+
}
|
|
9866
|
+
});
|
|
9488
9867
|
function updateStatus(idOrName, status) {
|
|
9489
9868
|
const store = new Store;
|
|
9490
9869
|
try {
|
|
@@ -9623,7 +10002,7 @@ daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) =>
|
|
|
9623
10002
|
console.log("");
|
|
9624
10003
|
return;
|
|
9625
10004
|
}
|
|
9626
|
-
const lines =
|
|
10005
|
+
const lines = readFileSync3(path, "utf8").trimEnd().split(`
|
|
9627
10006
|
`);
|
|
9628
10007
|
console.log(lines.slice(-Number(opts.lines)).join(`
|
|
9629
10008
|
`));
|
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.50",
|
|
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,11 @@ 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_]*)\}$/;
|
|
5181
5186
|
function compactJson(value) {
|
|
5182
5187
|
return JSON.stringify(value);
|
|
5183
5188
|
}
|
|
@@ -5455,11 +5460,310 @@ function workflowStepsWithWorktree(plan, steps) {
|
|
|
5455
5460
|
...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
|
|
5456
5461
|
];
|
|
5457
5462
|
}
|
|
5458
|
-
function
|
|
5459
|
-
|
|
5463
|
+
function assertRecord(value, label) {
|
|
5464
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5465
|
+
throw new Error(`${label} must be an object`);
|
|
5466
|
+
}
|
|
5467
|
+
function assertTemplateString(value, label) {
|
|
5468
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
5469
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
5470
|
+
return value.trim();
|
|
5471
|
+
}
|
|
5472
|
+
function assertTemplateKind(value, label) {
|
|
5473
|
+
const kind = assertTemplateString(value, label);
|
|
5474
|
+
if (kind !== "workflow")
|
|
5475
|
+
throw new Error(`${label} must be workflow; custom loop templates are not supported yet`);
|
|
5476
|
+
return kind;
|
|
5477
|
+
}
|
|
5478
|
+
function customLoopTemplatesDir() {
|
|
5479
|
+
return join5(dataDir(), "templates");
|
|
5480
|
+
}
|
|
5481
|
+
function ensureCustomLoopTemplatesDir() {
|
|
5482
|
+
const dir = customLoopTemplatesDir();
|
|
5483
|
+
mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
5484
|
+
return dir;
|
|
5485
|
+
}
|
|
5486
|
+
function loopTemplatesDir() {
|
|
5487
|
+
return customLoopTemplatesDir();
|
|
5488
|
+
}
|
|
5489
|
+
function builtinLoopTemplates() {
|
|
5490
|
+
return TEMPLATE_SUMMARIES.map((template) => ({ ...structuredClone(template), source: "builtin" }));
|
|
5491
|
+
}
|
|
5492
|
+
function getBuiltinLoopTemplate(id) {
|
|
5493
|
+
return builtinLoopTemplates().find((template) => template.id === id || template.name === id);
|
|
5494
|
+
}
|
|
5495
|
+
function builtinTemplateKeys() {
|
|
5496
|
+
const keys = new Set;
|
|
5497
|
+
for (const template of TEMPLATE_SUMMARIES) {
|
|
5498
|
+
keys.add(template.id);
|
|
5499
|
+
keys.add(template.name);
|
|
5500
|
+
}
|
|
5501
|
+
return keys;
|
|
5502
|
+
}
|
|
5503
|
+
function validateCustomTemplateId(id, label) {
|
|
5504
|
+
if (!CUSTOM_TEMPLATE_ID_PATTERN.test(id)) {
|
|
5505
|
+
throw new Error(`${label} must match ${CUSTOM_TEMPLATE_ID_PATTERN.source}`);
|
|
5506
|
+
}
|
|
5507
|
+
}
|
|
5508
|
+
function validateCustomTemplateVariables(value, label) {
|
|
5509
|
+
if (value === undefined)
|
|
5510
|
+
return [];
|
|
5511
|
+
if (!Array.isArray(value))
|
|
5512
|
+
throw new Error(`${label} must be an array`);
|
|
5513
|
+
const seen = new Set;
|
|
5514
|
+
return value.map((entry, index) => {
|
|
5515
|
+
const entryLabel = `${label}[${index}]`;
|
|
5516
|
+
assertRecord(entry, entryLabel);
|
|
5517
|
+
const name = assertTemplateString(entry.name, `${entryLabel}.name`);
|
|
5518
|
+
if (!CUSTOM_TEMPLATE_VARIABLE_PATTERN.test(name)) {
|
|
5519
|
+
throw new Error(`${entryLabel}.name must match ${CUSTOM_TEMPLATE_VARIABLE_PATTERN.source}`);
|
|
5520
|
+
}
|
|
5521
|
+
if (seen.has(name))
|
|
5522
|
+
throw new Error(`duplicate custom template variable: ${name}`);
|
|
5523
|
+
seen.add(name);
|
|
5524
|
+
const description = entry.description === undefined ? undefined : assertTemplateString(entry.description, `${entryLabel}.description`);
|
|
5525
|
+
const defaultValue = entry.default === undefined ? undefined : assertTemplateString(entry.default, `${entryLabel}.default`);
|
|
5526
|
+
const type = entry.type === undefined ? undefined : assertTemplateString(entry.type, `${entryLabel}.type`);
|
|
5527
|
+
if (type && !CUSTOM_TEMPLATE_VARIABLE_TYPES.has(type)) {
|
|
5528
|
+
throw new Error(`${entryLabel}.type must be one of ${[...CUSTOM_TEMPLATE_VARIABLE_TYPES].join(", ")}`);
|
|
5529
|
+
}
|
|
5530
|
+
if (defaultValue === "danger-full-access") {
|
|
5531
|
+
throw new Error(`${entryLabel}.default cannot be danger-full-access in a custom template`);
|
|
5532
|
+
}
|
|
5533
|
+
return {
|
|
5534
|
+
name,
|
|
5535
|
+
description,
|
|
5536
|
+
required: entry.required === undefined ? undefined : Boolean(entry.required),
|
|
5537
|
+
default: defaultValue,
|
|
5538
|
+
type
|
|
5539
|
+
};
|
|
5540
|
+
});
|
|
5541
|
+
}
|
|
5542
|
+
function assertNoDangerFullAccess(value, label) {
|
|
5543
|
+
if (!value || typeof value !== "object")
|
|
5544
|
+
return;
|
|
5545
|
+
if (Array.isArray(value)) {
|
|
5546
|
+
value.forEach((entry, index) => assertNoDangerFullAccess(entry, `${label}[${index}]`));
|
|
5547
|
+
return;
|
|
5548
|
+
}
|
|
5549
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
5550
|
+
if (key === "sandbox" && entry === "danger-full-access") {
|
|
5551
|
+
throw new Error(`${label}.${key} uses danger-full-access; custom templates must not request danger-full-access`);
|
|
5552
|
+
}
|
|
5553
|
+
assertNoDangerFullAccess(entry, `${label}.${key}`);
|
|
5554
|
+
}
|
|
5555
|
+
}
|
|
5556
|
+
function customTemplateDefinitionFromJson(value, sourcePath) {
|
|
5557
|
+
assertRecord(value, sourcePath);
|
|
5558
|
+
const id = assertTemplateString(value.id, `${sourcePath}.id`);
|
|
5559
|
+
validateCustomTemplateId(id, `${sourcePath}.id`);
|
|
5560
|
+
const name = assertTemplateString(value.name, `${sourcePath}.name`);
|
|
5561
|
+
const description = assertTemplateString(value.description, `${sourcePath}.description`);
|
|
5562
|
+
const kind = assertTemplateKind(value.kind ?? "workflow", `${sourcePath}.kind`);
|
|
5563
|
+
const variables = validateCustomTemplateVariables(value.variables, `${sourcePath}.variables`);
|
|
5564
|
+
if (value.workflow === undefined)
|
|
5565
|
+
throw new Error(`${sourcePath}.workflow is required`);
|
|
5566
|
+
assertRecord(value.workflow, `${sourcePath}.workflow`);
|
|
5567
|
+
assertNoDangerFullAccess(value.workflow, `${sourcePath}.workflow`);
|
|
5568
|
+
return { id, name, description, kind, variables, workflow: value.workflow };
|
|
5569
|
+
}
|
|
5570
|
+
function customTemplateSummary(definition, sourcePath) {
|
|
5571
|
+
return {
|
|
5572
|
+
id: definition.id,
|
|
5573
|
+
name: definition.name,
|
|
5574
|
+
description: definition.description,
|
|
5575
|
+
kind: definition.kind,
|
|
5576
|
+
variables: structuredClone(definition.variables),
|
|
5577
|
+
source: "custom",
|
|
5578
|
+
sourcePath
|
|
5579
|
+
};
|
|
5580
|
+
}
|
|
5581
|
+
function readCustomTemplateFile(file) {
|
|
5582
|
+
let parsed;
|
|
5583
|
+
try {
|
|
5584
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
5585
|
+
} catch (error) {
|
|
5586
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5587
|
+
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
5588
|
+
}
|
|
5589
|
+
const definition = customTemplateDefinitionFromJson(parsed, file);
|
|
5590
|
+
return { definition, summary: customTemplateSummary(definition, file), path: file };
|
|
5591
|
+
}
|
|
5592
|
+
function assertNoTemplateCollisions(entries) {
|
|
5593
|
+
const builtinKeys = builtinTemplateKeys();
|
|
5594
|
+
const seen = new Map;
|
|
5595
|
+
for (const entry of entries) {
|
|
5596
|
+
for (const key of [entry.definition.id, entry.definition.name]) {
|
|
5597
|
+
if (builtinKeys.has(key)) {
|
|
5598
|
+
throw new Error(`custom template ${entry.definition.id} collides with built-in template key ${key}; choose a different id or name`);
|
|
5599
|
+
}
|
|
5600
|
+
const existing = seen.get(key);
|
|
5601
|
+
if (existing) {
|
|
5602
|
+
throw new Error(`custom template ${entry.definition.id} collides with ${existing} on key ${key}`);
|
|
5603
|
+
}
|
|
5604
|
+
seen.set(key, entry.definition.id);
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
function loadCustomLoopTemplates() {
|
|
5609
|
+
const dir = customLoopTemplatesDir();
|
|
5610
|
+
if (!existsSync3(dir))
|
|
5611
|
+
return [];
|
|
5612
|
+
const entries = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
5613
|
+
const file = join5(dir, entry.name);
|
|
5614
|
+
if (entry.isSymbolicLink())
|
|
5615
|
+
throw new Error(`refusing symlinked custom template file: ${file}`);
|
|
5616
|
+
if (!entry.isFile())
|
|
5617
|
+
throw new Error(`custom template registry entry is not a regular file: ${file}`);
|
|
5618
|
+
return readCustomTemplateFile(file);
|
|
5619
|
+
});
|
|
5620
|
+
assertNoTemplateCollisions(entries);
|
|
5621
|
+
return entries;
|
|
5622
|
+
}
|
|
5623
|
+
function getCustomLoopTemplate(id) {
|
|
5624
|
+
return loadCustomLoopTemplates().find((template) => template.definition.id === id || template.definition.name === id);
|
|
5625
|
+
}
|
|
5626
|
+
function coerceCustomTemplateValue(raw, type, label) {
|
|
5627
|
+
const normalizedType = type ?? "string";
|
|
5628
|
+
if (normalizedType === "string")
|
|
5629
|
+
return String(raw);
|
|
5630
|
+
if (normalizedType === "number") {
|
|
5631
|
+
const value = typeof raw === "number" ? raw : Number(String(raw));
|
|
5632
|
+
if (!Number.isFinite(value))
|
|
5633
|
+
throw new Error(`${label} must be a finite number`);
|
|
5634
|
+
return value;
|
|
5635
|
+
}
|
|
5636
|
+
if (normalizedType === "boolean") {
|
|
5637
|
+
if (typeof raw === "boolean")
|
|
5638
|
+
return raw;
|
|
5639
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
5640
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
5641
|
+
return true;
|
|
5642
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
5643
|
+
return false;
|
|
5644
|
+
throw new Error(`${label} must be a boolean`);
|
|
5645
|
+
}
|
|
5646
|
+
if (normalizedType === "json") {
|
|
5647
|
+
if (typeof raw !== "string")
|
|
5648
|
+
return raw;
|
|
5649
|
+
try {
|
|
5650
|
+
return JSON.parse(raw);
|
|
5651
|
+
} catch (error) {
|
|
5652
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5653
|
+
throw new Error(`${label} must be valid JSON: ${message}`);
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5656
|
+
if (normalizedType === "string[]") {
|
|
5657
|
+
if (Array.isArray(raw))
|
|
5658
|
+
return raw.map((entry) => String(entry));
|
|
5659
|
+
return String(raw).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5660
|
+
}
|
|
5661
|
+
return String(raw);
|
|
5662
|
+
}
|
|
5663
|
+
function customTemplateValues(definition, values) {
|
|
5664
|
+
const variablesByName = new Map(definition.variables.map((variable) => [variable.name, variable]));
|
|
5665
|
+
for (const [name, value] of Object.entries(values)) {
|
|
5666
|
+
if (value !== undefined && !variablesByName.has(name)) {
|
|
5667
|
+
throw new Error(`unknown variable for custom template ${definition.id}: ${name}`);
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
5670
|
+
const rendered = {};
|
|
5671
|
+
for (const variable of definition.variables) {
|
|
5672
|
+
const raw = values[variable.name] ?? variable.default;
|
|
5673
|
+
if (raw === undefined || raw === "") {
|
|
5674
|
+
if (variable.required)
|
|
5675
|
+
throw new Error(`${variable.name} is required`);
|
|
5676
|
+
continue;
|
|
5677
|
+
}
|
|
5678
|
+
rendered[variable.name] = coerceCustomTemplateValue(raw, variable.type, variable.name);
|
|
5679
|
+
}
|
|
5680
|
+
return rendered;
|
|
5460
5681
|
}
|
|
5461
|
-
function
|
|
5462
|
-
|
|
5682
|
+
function customTemplateValueForPlaceholder(values, name, templateId) {
|
|
5683
|
+
if (!(name in values))
|
|
5684
|
+
throw new Error(`custom template ${templateId} requires variable ${name}`);
|
|
5685
|
+
return values[name];
|
|
5686
|
+
}
|
|
5687
|
+
function stringifyCustomTemplateValue(value, name) {
|
|
5688
|
+
if (typeof value === "string")
|
|
5689
|
+
return value;
|
|
5690
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
5691
|
+
return String(value);
|
|
5692
|
+
if (value === null)
|
|
5693
|
+
return "null";
|
|
5694
|
+
if (Array.isArray(value) || typeof value === "object")
|
|
5695
|
+
return JSON.stringify(value);
|
|
5696
|
+
throw new Error(`custom template variable ${name} cannot be rendered as a string`);
|
|
5697
|
+
}
|
|
5698
|
+
function renderCustomTemplateNode(value, values, templateId) {
|
|
5699
|
+
if (typeof value === "string") {
|
|
5700
|
+
const exact = CUSTOM_TEMPLATE_EXACT_PLACEHOLDER.exec(value);
|
|
5701
|
+
if (exact)
|
|
5702
|
+
return customTemplateValueForPlaceholder(values, exact[1], templateId);
|
|
5703
|
+
return value.replace(CUSTOM_TEMPLATE_PLACEHOLDER, (_match, name) => stringifyCustomTemplateValue(customTemplateValueForPlaceholder(values, name, templateId), name));
|
|
5704
|
+
}
|
|
5705
|
+
if (Array.isArray(value))
|
|
5706
|
+
return value.map((entry) => renderCustomTemplateNode(entry, values, templateId));
|
|
5707
|
+
if (!value || typeof value !== "object")
|
|
5708
|
+
return value;
|
|
5709
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderCustomTemplateNode(entry, values, templateId)]));
|
|
5710
|
+
}
|
|
5711
|
+
function renderCustomLoopTemplate(entry, values) {
|
|
5712
|
+
const renderedValues = customTemplateValues(entry.definition, values);
|
|
5713
|
+
const rendered = renderCustomTemplateNode(entry.definition.workflow, renderedValues, entry.definition.id);
|
|
5714
|
+
assertNoDangerFullAccess(rendered, `custom template ${entry.definition.id}.workflow`);
|
|
5715
|
+
return workflowBodyFromJson(rendered);
|
|
5716
|
+
}
|
|
5717
|
+
function validateCustomLoopTemplateFile(file) {
|
|
5718
|
+
const entry = readCustomTemplateFile(resolve(file));
|
|
5719
|
+
assertNoTemplateCollisions([entry]);
|
|
5720
|
+
return structuredClone(entry.summary);
|
|
5721
|
+
}
|
|
5722
|
+
function importCustomLoopTemplate(file, opts = {}) {
|
|
5723
|
+
const source = resolve(file);
|
|
5724
|
+
const entry = readCustomTemplateFile(source);
|
|
5725
|
+
assertNoTemplateCollisions([entry]);
|
|
5726
|
+
const dir = ensureCustomLoopTemplatesDir();
|
|
5727
|
+
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
5728
|
+
const replaced = existsSync3(destination);
|
|
5729
|
+
if (replaced) {
|
|
5730
|
+
const stat = lstatSync(destination);
|
|
5731
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
5732
|
+
throw new Error(`refusing to replace non-regular custom template file: ${destination}`);
|
|
5733
|
+
if (!opts.replace)
|
|
5734
|
+
throw new Error(`custom template already exists: ${entry.definition.id}; use --replace to overwrite it`);
|
|
5735
|
+
}
|
|
5736
|
+
writeFileSync2(destination, `${JSON.stringify(entry.definition, null, 2)}
|
|
5737
|
+
`, { mode: 384 });
|
|
5738
|
+
const imported = readCustomTemplateFile(destination);
|
|
5739
|
+
return { template: structuredClone(imported.summary), path: destination, replaced };
|
|
5740
|
+
}
|
|
5741
|
+
function validateLoopTemplateRegistry(opts = {}) {
|
|
5742
|
+
return {
|
|
5743
|
+
ok: true,
|
|
5744
|
+
templates: listLoopTemplates(opts),
|
|
5745
|
+
customDir: customLoopTemplatesDir()
|
|
5746
|
+
};
|
|
5747
|
+
}
|
|
5748
|
+
function listLoopTemplates(opts = {}) {
|
|
5749
|
+
const source = opts.source ?? "all";
|
|
5750
|
+
const templates = [];
|
|
5751
|
+
if (source !== "custom")
|
|
5752
|
+
templates.push(...builtinLoopTemplates());
|
|
5753
|
+
if (source !== "builtin")
|
|
5754
|
+
templates.push(...loadCustomLoopTemplates().map((entry) => structuredClone(entry.summary)));
|
|
5755
|
+
return templates;
|
|
5756
|
+
}
|
|
5757
|
+
function getLoopTemplate(id, opts = {}) {
|
|
5758
|
+
const source = opts.source ?? "all";
|
|
5759
|
+
if (source !== "custom") {
|
|
5760
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
5761
|
+
if (builtin)
|
|
5762
|
+
return builtin;
|
|
5763
|
+
if (source === "builtin")
|
|
5764
|
+
return;
|
|
5765
|
+
}
|
|
5766
|
+
return getCustomLoopTemplate(id)?.summary;
|
|
5463
5767
|
}
|
|
5464
5768
|
function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
5465
5769
|
if (!input.taskId?.trim())
|
|
@@ -5804,7 +6108,7 @@ function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
|
5804
6108
|
]
|
|
5805
6109
|
};
|
|
5806
6110
|
}
|
|
5807
|
-
function
|
|
6111
|
+
function renderBuiltinLoopTemplate(id, values) {
|
|
5808
6112
|
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
5809
6113
|
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
5810
6114
|
}
|
|
@@ -5918,12 +6222,26 @@ function booleanVar(value) {
|
|
|
5918
6222
|
function accountPoolVar(value, tool) {
|
|
5919
6223
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
5920
6224
|
}
|
|
6225
|
+
function renderLoopTemplate(id, values, opts = {}) {
|
|
6226
|
+
const source = opts.source ?? "all";
|
|
6227
|
+
if (source !== "custom") {
|
|
6228
|
+
const builtin = getBuiltinLoopTemplate(id);
|
|
6229
|
+
if (builtin)
|
|
6230
|
+
return renderBuiltinLoopTemplate(builtin.id, values);
|
|
6231
|
+
if (source === "builtin")
|
|
6232
|
+
throw new Error(`unknown built-in template: ${id}`);
|
|
6233
|
+
}
|
|
6234
|
+
const custom = getCustomLoopTemplate(id);
|
|
6235
|
+
if (custom)
|
|
6236
|
+
return renderCustomLoopTemplate(custom, values);
|
|
6237
|
+
throw new Error(`unknown template: ${id}`);
|
|
6238
|
+
}
|
|
5921
6239
|
// src/lib/doctor.ts
|
|
5922
6240
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
5923
6241
|
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
5924
6242
|
|
|
5925
6243
|
// src/daemon/control.ts
|
|
5926
|
-
import { existsSync as existsSync4, mkdirSync as
|
|
6244
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
5927
6245
|
import { hostname } from "os";
|
|
5928
6246
|
import { dirname as dirname2 } from "path";
|
|
5929
6247
|
|
|
@@ -5954,15 +6272,15 @@ function readPid(path = pidFilePath()) {
|
|
|
5954
6272
|
if (!existsSync4(path))
|
|
5955
6273
|
return;
|
|
5956
6274
|
try {
|
|
5957
|
-
const pid = Number(
|
|
6275
|
+
const pid = Number(readFileSync2(path, "utf8").trim());
|
|
5958
6276
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
5959
6277
|
} catch {
|
|
5960
6278
|
return;
|
|
5961
6279
|
}
|
|
5962
6280
|
}
|
|
5963
6281
|
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
5964
|
-
|
|
5965
|
-
|
|
6282
|
+
mkdirSync5(dirname2(path), { recursive: true, mode: 448 });
|
|
6283
|
+
writeFileSync3(path, String(pid));
|
|
5966
6284
|
}
|
|
5967
6285
|
function removePid(path = pidFilePath()) {
|
|
5968
6286
|
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,65 @@ 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`; use built-in templates
|
|
313
|
+
with explicit break-glass handling for emergency workflows that need that
|
|
314
|
+
sandbox.
|
|
315
|
+
|
|
257
316
|
Repo-mutating task/event routes should set `worktreeMode=required` so the
|
|
258
317
|
workflow fails fast instead of falling back to the main checkout. When
|
|
259
318
|
`projectPath` is an existing git repository, OpenLoops inserts a
|
|
@@ -554,6 +613,20 @@ production loop. Route commands store a small cursor in
|
|
|
554
613
|
through all findings over repeated scheduled runs instead of reprocessing only
|
|
555
614
|
the first batch.
|
|
556
615
|
|
|
616
|
+
For a deliberate operator rename, use the first-class rename command instead of
|
|
617
|
+
turning a one-off naming preference into hygiene policy:
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
loops rename machine-todos-drain-oss-repos-strict-5m machine-todos-drain-oss-repos
|
|
621
|
+
loops --json rename <loop-id> machine-ops-loop-health-route-tasks
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`rename` preserves the loop id, schedule, run history, archive state, and target
|
|
625
|
+
configuration. It rejects empty or duplicate names and writes a SQLite backup
|
|
626
|
+
under `<LOOPS_DATA_DIR>/backups` before mutating the loop row. Human-facing
|
|
627
|
+
names should describe scope and responsibility; cadence belongs in schedule
|
|
628
|
+
metadata and operator tables.
|
|
629
|
+
|
|
557
630
|
Archive loops when retiring old automation but preserving history:
|
|
558
631
|
|
|
559
632
|
```bash
|
package/package.json
CHANGED