@hasna/loops 0.3.49 → 0.3.51

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