@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 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 mkdirSync6, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync2, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
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.49",
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 listLoopTemplates() {
6480
- return TEMPLATE_SUMMARIES.map((template) => structuredClone(template));
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 getLoopTemplate(id) {
6483
- return listLoopTemplates().find((template) => template.id === id || template.name === id);
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 renderLoopTemplate(id, values) {
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: readFileSync2(stdoutPath, "utf8"),
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
- mkdirSync6(backupDir, { recursive: true, mode: 448 });
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
- writeFileSync4(backupPath, db.serialize(), { mode: 384 });
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(readFileSync2(path, "utf8"));
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
- writeFileSync4(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
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
- mkdirSync6(evidenceDir, { recursive: true, mode: 448 });
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
- writeFileSync4(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
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 ? readFileSync2(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
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 built-in OpenLoops templates").action(() => {
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("show <id>").description("show a built-in template").action((id) => {
8659
- const template = getLoopTemplate(id);
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(readFileSync2(file, "utf8")), opts.name);
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(readFileSync2(file, "utf8")), opts.name);
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 = readFileSync2(path, "utf8").trimEnd().split(`
10005
+ const lines = readFileSync3(path, "utf8").trimEnd().split(`
9627
10006
  `);
9628
10007
  console.log(lines.slice(-Number(opts.lines)).join(`
9629
10008
  `));
@@ -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.49",
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 listLoopTemplates() {
5459
- return TEMPLATE_SUMMARIES.map((template) => structuredClone(template));
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 getLoopTemplate(id) {
5462
- return listLoopTemplates().find((template) => template.id === id || template.name === id);
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 renderLoopTemplate(id, values) {
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 mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
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(readFileSync(path, "utf8").trim());
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
- mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
5965
- writeFileSync2(path, String(pid));
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 });
@@ -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 declare function listLoopTemplates(): LoopTemplateSummary[];
101
- export declare function getLoopTemplate(id: string): LoopTemplateSummary | undefined;
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>): CreateWorkflowInput;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.49",
3
+ "version": "0.3.50",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",