@hasna/loops 0.3.53 → 0.3.54

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.
@@ -314,6 +314,8 @@ function updateReadyFlags(nodes, planStatus) {
314
314
  }
315
315
 
316
316
  // src/lib/workflow-spec.ts
317
+ import { readFileSync } from "fs";
318
+ import { isAbsolute, resolve } from "path";
317
319
  function assertObject(value, label) {
318
320
  if (!value || typeof value !== "object" || Array.isArray(value))
319
321
  throw new Error(`${label} must be an object`);
@@ -359,6 +361,38 @@ function optionalAccountRef(value, label) {
359
361
  tool: typeof value.tool === "string" ? value.tool.trim() : undefined
360
362
  };
361
363
  }
364
+ function promptFilePath(rawPath, opts) {
365
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
366
+ }
367
+ function readPromptFile(rawPath, label, opts) {
368
+ assertString(rawPath, `${label}.promptFile`);
369
+ const path = promptFilePath(rawPath.trim(), opts);
370
+ let prompt;
371
+ try {
372
+ prompt = readFileSync(path, "utf8");
373
+ } catch (error) {
374
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
375
+ throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
376
+ }
377
+ if (prompt.trim() === "")
378
+ throw new Error(`${label}.promptFile must contain a non-empty prompt`);
379
+ return { prompt, promptSource: { type: "file", path } };
380
+ }
381
+ function matchingPromptSource(value, prompt, opts) {
382
+ if (!value || typeof value !== "object" || Array.isArray(value))
383
+ return;
384
+ if (value.type !== "file")
385
+ return;
386
+ const rawPath = value.path;
387
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
388
+ return;
389
+ const path = promptFilePath(rawPath.trim(), opts);
390
+ try {
391
+ return readFileSync(path, "utf8") === prompt ? { type: "file", path } : undefined;
392
+ } catch {
393
+ return;
394
+ }
395
+ }
362
396
  function normalizeGoalSpec(value, label = "goal") {
363
397
  if (value === undefined)
364
398
  return;
@@ -381,7 +415,7 @@ function normalizeGoalSpec(value, label = "goal") {
381
415
  autoExecute
382
416
  };
383
417
  }
384
- function validateTarget(value, label) {
418
+ function validateTarget(value, label, opts) {
385
419
  assertObject(value, label);
386
420
  if (value.type === "command") {
387
421
  assertString(value.command, `${label}.command`);
@@ -394,7 +428,13 @@ function validateTarget(value, label) {
394
428
  }
395
429
  if (value.type === "agent") {
396
430
  assertString(value.provider, `${label}.provider`);
397
- assertString(value.prompt, `${label}.prompt`);
431
+ const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
432
+ const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
433
+ if (hasPrompt && hasPromptFile)
434
+ throw new Error(`${label} must use either prompt or promptFile, not both`);
435
+ if (!hasPrompt && !hasPromptFile)
436
+ throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
437
+ const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
398
438
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
399
439
  if (!providers.includes(value.provider))
400
440
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
@@ -495,11 +535,14 @@ function validateTarget(value, label) {
495
535
  if (value.routing.eventSource !== undefined)
496
536
  assertString(value.routing.eventSource, `${label}.routing.eventSource`);
497
537
  }
498
- return value;
538
+ const target = { ...value };
539
+ delete target.promptFile;
540
+ delete target.promptSource;
541
+ return { ...target, ...promptFields };
499
542
  }
500
543
  throw new Error(`${label}.type must be command or agent`);
501
544
  }
502
- function normalizeCreateWorkflowInput(input) {
545
+ function normalizeCreateWorkflowInput(input, opts = {}) {
503
546
  assertString(input.name, "workflow.name");
504
547
  const goal = normalizeGoalSpec(input.goal, "goal");
505
548
  if (!Array.isArray(input.steps) || input.steps.length === 0)
@@ -515,7 +558,7 @@ function normalizeCreateWorkflowInput(input) {
515
558
  ...step,
516
559
  id: step.id,
517
560
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
518
- target: validateTarget(step.target, `workflow.steps[${index}].target`),
561
+ target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
519
562
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
520
563
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
521
564
  timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
@@ -558,7 +601,7 @@ function workflowExecutionOrder(workflow) {
558
601
  visit(step);
559
602
  return order;
560
603
  }
561
- function workflowBodyFromJson(value, fallbackName) {
604
+ function workflowBodyFromJson(value, fallbackName, opts = {}) {
562
605
  assertObject(value, "workflow file");
563
606
  const rawName = fallbackName ?? value.name;
564
607
  assertString(rawName, "workflow.name");
@@ -570,7 +613,7 @@ function workflowBodyFromJson(value, fallbackName) {
570
613
  goal: normalizeGoalSpec(value.goal, "goal"),
571
614
  version: typeof value.version === "number" ? value.version : undefined,
572
615
  steps: value.steps
573
- });
616
+ }, opts);
574
617
  }
575
618
 
576
619
  // src/lib/run-artifacts.ts
@@ -1207,13 +1250,17 @@ class Store {
1207
1250
  }
1208
1251
  createLoop(input, from = new Date) {
1209
1252
  const now = nowIso();
1253
+ const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1254
+ name: "loop-target-validation",
1255
+ steps: [{ id: "target", target: input.target }]
1256
+ }).steps[0].target;
1210
1257
  const loop = {
1211
1258
  id: genId(),
1212
1259
  name: input.name,
1213
1260
  description: input.description,
1214
1261
  status: "active",
1215
1262
  schedule: input.schedule,
1216
- target: input.target,
1263
+ target,
1217
1264
  goal: input.goal,
1218
1265
  machine: input.machine,
1219
1266
  nextRunAt: initialNextRun(input.schedule, from),
@@ -4915,13 +4962,13 @@ async function tick(deps) {
4915
4962
  }
4916
4963
 
4917
4964
  // src/daemon/control.ts
4918
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4965
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4919
4966
  import { hostname } from "os";
4920
4967
  import { dirname as dirname2 } from "path";
4921
4968
 
4922
4969
  // src/daemon/loop.ts
4923
4970
  function realSleep(ms) {
4924
- return new Promise((resolve) => setTimeout(resolve, ms));
4971
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
4925
4972
  }
4926
4973
  async function runLoop(opts) {
4927
4974
  const sleep = opts.sleep ?? realSleep;
@@ -4946,7 +4993,7 @@ function readPid(path = pidFilePath()) {
4946
4993
  if (!existsSync3(path))
4947
4994
  return;
4948
4995
  try {
4949
- const pid = Number(readFileSync(path, "utf8").trim());
4996
+ const pid = Number(readFileSync2(path, "utf8").trim());
4950
4997
  return Number.isInteger(pid) && pid > 0 ? pid : undefined;
4951
4998
  } catch {
4952
4999
  return;
@@ -5299,7 +5346,7 @@ function enableStartup(result) {
5299
5346
  // package.json
5300
5347
  var package_default = {
5301
5348
  name: "@hasna/loops",
5302
- version: "0.3.53",
5349
+ version: "0.3.54",
5303
5350
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5304
5351
  type: "module",
5305
5352
  main: "dist/index.js",
package/dist/index.js CHANGED
@@ -312,6 +312,8 @@ function updateReadyFlags(nodes, planStatus) {
312
312
  }
313
313
 
314
314
  // src/lib/workflow-spec.ts
315
+ import { readFileSync } from "fs";
316
+ import { isAbsolute, resolve } from "path";
315
317
  function assertObject(value, label) {
316
318
  if (!value || typeof value !== "object" || Array.isArray(value))
317
319
  throw new Error(`${label} must be an object`);
@@ -357,6 +359,38 @@ function optionalAccountRef(value, label) {
357
359
  tool: typeof value.tool === "string" ? value.tool.trim() : undefined
358
360
  };
359
361
  }
362
+ function promptFilePath(rawPath, opts) {
363
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
364
+ }
365
+ function readPromptFile(rawPath, label, opts) {
366
+ assertString(rawPath, `${label}.promptFile`);
367
+ const path = promptFilePath(rawPath.trim(), opts);
368
+ let prompt;
369
+ try {
370
+ prompt = readFileSync(path, "utf8");
371
+ } catch (error) {
372
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
373
+ throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
374
+ }
375
+ if (prompt.trim() === "")
376
+ throw new Error(`${label}.promptFile must contain a non-empty prompt`);
377
+ return { prompt, promptSource: { type: "file", path } };
378
+ }
379
+ function matchingPromptSource(value, prompt, opts) {
380
+ if (!value || typeof value !== "object" || Array.isArray(value))
381
+ return;
382
+ if (value.type !== "file")
383
+ return;
384
+ const rawPath = value.path;
385
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
386
+ return;
387
+ const path = promptFilePath(rawPath.trim(), opts);
388
+ try {
389
+ return readFileSync(path, "utf8") === prompt ? { type: "file", path } : undefined;
390
+ } catch {
391
+ return;
392
+ }
393
+ }
360
394
  function normalizeGoalSpec(value, label = "goal") {
361
395
  if (value === undefined)
362
396
  return;
@@ -379,7 +413,7 @@ function normalizeGoalSpec(value, label = "goal") {
379
413
  autoExecute
380
414
  };
381
415
  }
382
- function validateTarget(value, label) {
416
+ function validateTarget(value, label, opts) {
383
417
  assertObject(value, label);
384
418
  if (value.type === "command") {
385
419
  assertString(value.command, `${label}.command`);
@@ -392,7 +426,13 @@ function validateTarget(value, label) {
392
426
  }
393
427
  if (value.type === "agent") {
394
428
  assertString(value.provider, `${label}.provider`);
395
- assertString(value.prompt, `${label}.prompt`);
429
+ const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
430
+ const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
431
+ if (hasPrompt && hasPromptFile)
432
+ throw new Error(`${label} must use either prompt or promptFile, not both`);
433
+ if (!hasPrompt && !hasPromptFile)
434
+ throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
435
+ const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
396
436
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
397
437
  if (!providers.includes(value.provider))
398
438
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
@@ -493,11 +533,14 @@ function validateTarget(value, label) {
493
533
  if (value.routing.eventSource !== undefined)
494
534
  assertString(value.routing.eventSource, `${label}.routing.eventSource`);
495
535
  }
496
- return value;
536
+ const target = { ...value };
537
+ delete target.promptFile;
538
+ delete target.promptSource;
539
+ return { ...target, ...promptFields };
497
540
  }
498
541
  throw new Error(`${label}.type must be command or agent`);
499
542
  }
500
- function normalizeCreateWorkflowInput(input) {
543
+ function normalizeCreateWorkflowInput(input, opts = {}) {
501
544
  assertString(input.name, "workflow.name");
502
545
  const goal = normalizeGoalSpec(input.goal, "goal");
503
546
  if (!Array.isArray(input.steps) || input.steps.length === 0)
@@ -513,7 +556,7 @@ function normalizeCreateWorkflowInput(input) {
513
556
  ...step,
514
557
  id: step.id,
515
558
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
516
- target: validateTarget(step.target, `workflow.steps[${index}].target`),
559
+ target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
517
560
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
518
561
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
519
562
  timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
@@ -556,7 +599,7 @@ function workflowExecutionOrder(workflow) {
556
599
  visit(step);
557
600
  return order;
558
601
  }
559
- function workflowBodyFromJson(value, fallbackName) {
602
+ function workflowBodyFromJson(value, fallbackName, opts = {}) {
560
603
  assertObject(value, "workflow file");
561
604
  const rawName = fallbackName ?? value.name;
562
605
  assertString(rawName, "workflow.name");
@@ -568,7 +611,7 @@ function workflowBodyFromJson(value, fallbackName) {
568
611
  goal: normalizeGoalSpec(value.goal, "goal"),
569
612
  version: typeof value.version === "number" ? value.version : undefined,
570
613
  steps: value.steps
571
- });
614
+ }, opts);
572
615
  }
573
616
 
574
617
  // src/lib/run-artifacts.ts
@@ -1205,13 +1248,17 @@ class Store {
1205
1248
  }
1206
1249
  createLoop(input, from = new Date) {
1207
1250
  const now = nowIso();
1251
+ const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1252
+ name: "loop-target-validation",
1253
+ steps: [{ id: "target", target: input.target }]
1254
+ }).steps[0].target;
1208
1255
  const loop = {
1209
1256
  id: genId(),
1210
1257
  name: input.name,
1211
1258
  description: input.description,
1212
1259
  status: "active",
1213
1260
  schedule: input.schedule,
1214
- target: input.target,
1261
+ target,
1215
1262
  goal: input.goal,
1216
1263
  machine: input.machine,
1217
1264
  nextRunAt: initialNextRun(input.schedule, from),
@@ -5036,9 +5083,9 @@ function openAutomationsRuntimeBinding(overrides = {}) {
5036
5083
  }
5037
5084
  // src/lib/templates.ts
5038
5085
  import { execFileSync } from "child_process";
5039
- import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readdirSync, readFileSync, writeFileSync as writeFileSync2 } from "fs";
5086
+ import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
5040
5087
  import { homedir as homedir3 } from "os";
5041
- import { basename as basename2, isAbsolute, join as join5, relative, resolve } from "path";
5088
+ import { basename as basename2, isAbsolute as isAbsolute2, join as join5, relative, resolve as resolve2 } from "path";
5042
5089
  var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
5043
5090
  var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
5044
5091
  var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
@@ -5320,7 +5367,7 @@ function normalizeWorktreeMode(mode) {
5320
5367
  function defaultWorktreeRoot(root) {
5321
5368
  if (root?.trim()) {
5322
5369
  const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
5323
- return isAbsolute(expanded) ? expanded : resolve(expanded);
5370
+ return isAbsolute2(expanded) ? expanded : resolve2(expanded);
5324
5371
  }
5325
5372
  return join5(homedir3(), ".hasna", "loops", "worktrees");
5326
5373
  }
@@ -5427,7 +5474,7 @@ function worktreePlan(input, seed) {
5427
5474
  const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
5428
5475
  const worktreePath = join5(root, repoSlug, seedSlug);
5429
5476
  const relativeCwd = relative(repoRoot, originalCwd);
5430
- const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
5477
+ const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute2(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
5431
5478
  const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
5432
5479
  const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
5433
5480
  const prepareStep = {
@@ -5662,9 +5709,24 @@ function assertNoImplicitDangerFullAccess(value, label) {
5662
5709
  assertNoImplicitDangerFullAccess(entry, `${label}.${key}`);
5663
5710
  }
5664
5711
  }
5712
+ function assertNoCustomTemplatePromptFiles(value, label) {
5713
+ if (!value || typeof value !== "object")
5714
+ return;
5715
+ if (Array.isArray(value)) {
5716
+ value.forEach((entry, index) => assertNoCustomTemplatePromptFiles(entry, `${label}[${index}]`));
5717
+ return;
5718
+ }
5719
+ for (const [key, entry] of Object.entries(value)) {
5720
+ if (key === "promptFile") {
5721
+ throw new Error(`${label}.${key} is not allowed in custom templates; use direct workflow JSON for prompt-file-backed workflows`);
5722
+ }
5723
+ assertNoCustomTemplatePromptFiles(entry, `${label}.${key}`);
5724
+ }
5725
+ }
5665
5726
  function assertCustomTemplateSafety(value, label) {
5666
5727
  assertNoDangerousCustomTemplateScalars(value, label);
5667
5728
  assertNoImplicitDangerFullAccess(value, label);
5729
+ assertNoCustomTemplatePromptFiles(value, label);
5668
5730
  }
5669
5731
  function customTemplateDefinitionFromJson(value, sourcePath) {
5670
5732
  assertRecord(value, sourcePath);
@@ -5694,7 +5756,7 @@ function customTemplateSummary(definition, sourcePath) {
5694
5756
  function readCustomTemplateFile(file) {
5695
5757
  let parsed;
5696
5758
  try {
5697
- parsed = JSON.parse(readFileSync(file, "utf8"));
5759
+ parsed = JSON.parse(readFileSync2(file, "utf8"));
5698
5760
  } catch (error) {
5699
5761
  const message = error instanceof Error ? error.message : String(error);
5700
5762
  throw new Error(`failed to read custom template ${file}: ${message}`);
@@ -5833,19 +5895,19 @@ function renderCustomLoopTemplate(entry, values) {
5833
5895
  return workflow;
5834
5896
  }
5835
5897
  function validateCustomLoopTemplateFile(file) {
5836
- const source = resolve(file);
5898
+ const source = resolve2(file);
5837
5899
  const entry = readCustomTemplateFile(source);
5838
- const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== source);
5900
+ const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== source);
5839
5901
  assertNoTemplateCollisions([...existing, entry]);
5840
5902
  return structuredClone(entry.summary);
5841
5903
  }
5842
5904
  function importCustomLoopTemplate(file, opts = {}) {
5843
- const source = resolve(file);
5905
+ const source = resolve2(file);
5844
5906
  const entry = readCustomTemplateFile(source);
5845
5907
  const dir = ensureCustomLoopTemplatesDir();
5846
5908
  const destination = join5(dir, `${entry.definition.id}.json`);
5847
5909
  const replaced = existsSync3(destination);
5848
- const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== resolve(destination));
5910
+ const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== resolve2(destination));
5849
5911
  assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
5850
5912
  if (replaced) {
5851
5913
  const stat = lstatSync(destination);
@@ -6606,13 +6668,13 @@ import { spawnSync as spawnSync3 } from "child_process";
6606
6668
  import { accessSync as accessSync2, constants as constants2 } from "fs";
6607
6669
 
6608
6670
  // src/daemon/control.ts
6609
- import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
6671
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
6610
6672
  import { hostname } from "os";
6611
6673
  import { dirname as dirname2 } from "path";
6612
6674
 
6613
6675
  // src/daemon/loop.ts
6614
6676
  function realSleep(ms) {
6615
- return new Promise((resolve2) => setTimeout(resolve2, ms));
6677
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
6616
6678
  }
6617
6679
  async function runLoop(opts) {
6618
6680
  const sleep = opts.sleep ?? realSleep;
@@ -6637,7 +6699,7 @@ function readPid(path = pidFilePath()) {
6637
6699
  if (!existsSync4(path))
6638
6700
  return;
6639
6701
  try {
6640
- const pid = Number(readFileSync2(path, "utf8").trim());
6702
+ const pid = Number(readFileSync3(path, "utf8").trim());
6641
6703
  return Number.isInteger(pid) && pid > 0 ? pid : undefined;
6642
6704
  } catch {
6643
6705
  return;
package/dist/lib/store.js CHANGED
@@ -312,6 +312,8 @@ function updateReadyFlags(nodes, planStatus) {
312
312
  }
313
313
 
314
314
  // src/lib/workflow-spec.ts
315
+ import { readFileSync } from "fs";
316
+ import { isAbsolute, resolve } from "path";
315
317
  function assertObject(value, label) {
316
318
  if (!value || typeof value !== "object" || Array.isArray(value))
317
319
  throw new Error(`${label} must be an object`);
@@ -357,6 +359,38 @@ function optionalAccountRef(value, label) {
357
359
  tool: typeof value.tool === "string" ? value.tool.trim() : undefined
358
360
  };
359
361
  }
362
+ function promptFilePath(rawPath, opts) {
363
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
364
+ }
365
+ function readPromptFile(rawPath, label, opts) {
366
+ assertString(rawPath, `${label}.promptFile`);
367
+ const path = promptFilePath(rawPath.trim(), opts);
368
+ let prompt;
369
+ try {
370
+ prompt = readFileSync(path, "utf8");
371
+ } catch (error) {
372
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
373
+ throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
374
+ }
375
+ if (prompt.trim() === "")
376
+ throw new Error(`${label}.promptFile must contain a non-empty prompt`);
377
+ return { prompt, promptSource: { type: "file", path } };
378
+ }
379
+ function matchingPromptSource(value, prompt, opts) {
380
+ if (!value || typeof value !== "object" || Array.isArray(value))
381
+ return;
382
+ if (value.type !== "file")
383
+ return;
384
+ const rawPath = value.path;
385
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
386
+ return;
387
+ const path = promptFilePath(rawPath.trim(), opts);
388
+ try {
389
+ return readFileSync(path, "utf8") === prompt ? { type: "file", path } : undefined;
390
+ } catch {
391
+ return;
392
+ }
393
+ }
360
394
  function normalizeGoalSpec(value, label = "goal") {
361
395
  if (value === undefined)
362
396
  return;
@@ -379,7 +413,7 @@ function normalizeGoalSpec(value, label = "goal") {
379
413
  autoExecute
380
414
  };
381
415
  }
382
- function validateTarget(value, label) {
416
+ function validateTarget(value, label, opts) {
383
417
  assertObject(value, label);
384
418
  if (value.type === "command") {
385
419
  assertString(value.command, `${label}.command`);
@@ -392,7 +426,13 @@ function validateTarget(value, label) {
392
426
  }
393
427
  if (value.type === "agent") {
394
428
  assertString(value.provider, `${label}.provider`);
395
- assertString(value.prompt, `${label}.prompt`);
429
+ const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
430
+ const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
431
+ if (hasPrompt && hasPromptFile)
432
+ throw new Error(`${label} must use either prompt or promptFile, not both`);
433
+ if (!hasPrompt && !hasPromptFile)
434
+ throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
435
+ const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
396
436
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
397
437
  if (!providers.includes(value.provider))
398
438
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
@@ -493,11 +533,14 @@ function validateTarget(value, label) {
493
533
  if (value.routing.eventSource !== undefined)
494
534
  assertString(value.routing.eventSource, `${label}.routing.eventSource`);
495
535
  }
496
- return value;
536
+ const target = { ...value };
537
+ delete target.promptFile;
538
+ delete target.promptSource;
539
+ return { ...target, ...promptFields };
497
540
  }
498
541
  throw new Error(`${label}.type must be command or agent`);
499
542
  }
500
- function normalizeCreateWorkflowInput(input) {
543
+ function normalizeCreateWorkflowInput(input, opts = {}) {
501
544
  assertString(input.name, "workflow.name");
502
545
  const goal = normalizeGoalSpec(input.goal, "goal");
503
546
  if (!Array.isArray(input.steps) || input.steps.length === 0)
@@ -513,7 +556,7 @@ function normalizeCreateWorkflowInput(input) {
513
556
  ...step,
514
557
  id: step.id,
515
558
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
516
- target: validateTarget(step.target, `workflow.steps[${index}].target`),
559
+ target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
517
560
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
518
561
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
519
562
  timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
@@ -556,7 +599,7 @@ function workflowExecutionOrder(workflow) {
556
599
  visit(step);
557
600
  return order;
558
601
  }
559
- function workflowBodyFromJson(value, fallbackName) {
602
+ function workflowBodyFromJson(value, fallbackName, opts = {}) {
560
603
  assertObject(value, "workflow file");
561
604
  const rawName = fallbackName ?? value.name;
562
605
  assertString(rawName, "workflow.name");
@@ -568,7 +611,7 @@ function workflowBodyFromJson(value, fallbackName) {
568
611
  goal: normalizeGoalSpec(value.goal, "goal"),
569
612
  version: typeof value.version === "number" ? value.version : undefined,
570
613
  steps: value.steps
571
- });
614
+ }, opts);
572
615
  }
573
616
 
574
617
  // src/lib/run-artifacts.ts
@@ -1205,13 +1248,17 @@ class Store {
1205
1248
  }
1206
1249
  createLoop(input, from = new Date) {
1207
1250
  const now = nowIso();
1251
+ const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1252
+ name: "loop-target-validation",
1253
+ steps: [{ id: "target", target: input.target }]
1254
+ }).steps[0].target;
1208
1255
  const loop = {
1209
1256
  id: genId(),
1210
1257
  name: input.name,
1211
1258
  description: input.description,
1212
1259
  status: "active",
1213
1260
  schedule: input.schedule,
1214
- target: input.target,
1261
+ target,
1215
1262
  goal: input.goal,
1216
1263
  machine: input.machine,
1217
1264
  nextRunAt: initialNextRun(input.schedule, from),
@@ -1,6 +1,12 @@
1
1
  import type { CreateWorkflowInput, GoalSpec, WorkflowSpec, WorkflowStep } from "../types.js";
2
2
  export type WorkflowSpecBody = Pick<WorkflowSpec, "name" | "description" | "version" | "steps">;
3
+ export type NormalizedCreateWorkflowInput = Omit<CreateWorkflowInput, "steps"> & {
4
+ steps: WorkflowStep[];
5
+ };
6
+ export interface WorkflowNormalizeOptions {
7
+ baseDir?: string;
8
+ }
3
9
  export declare function normalizeGoalSpec(value: unknown, label?: string): GoalSpec | undefined;
4
- export declare function normalizeCreateWorkflowInput(input: CreateWorkflowInput): CreateWorkflowInput;
10
+ export declare function normalizeCreateWorkflowInput(input: CreateWorkflowInput, opts?: WorkflowNormalizeOptions): NormalizedCreateWorkflowInput;
5
11
  export declare function workflowExecutionOrder(workflow: Pick<WorkflowSpec, "steps">): WorkflowStep[];
6
- export declare function workflowBodyFromJson(value: unknown, fallbackName?: string): CreateWorkflowInput;
12
+ export declare function workflowBodyFromJson(value: unknown, fallbackName?: string, opts?: WorkflowNormalizeOptions): NormalizedCreateWorkflowInput;