@hasna/loops 0.3.52 → 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.
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),
@@ -1565,6 +1612,65 @@ class Store {
1565
1612
  throw new Error(`workflow invocation not found after create: ${id}`);
1566
1613
  return rowToWorkflowInvocation(row);
1567
1614
  }
1615
+ refreshWorkflowInvocationForWorkItem(workItemId, input) {
1616
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
1617
+ if (!sourceDedupeKey)
1618
+ throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1619
+ const now = nowIso();
1620
+ const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1621
+ const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1622
+ const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1623
+ const result = this.db.query(`UPDATE workflow_invocations
1624
+ SET workflow_id=COALESCE($workflowId, workflow_id),
1625
+ template_id=COALESCE($templateId, template_id),
1626
+ source_id=COALESCE($sourceId, source_id),
1627
+ source_json=$sourceJson,
1628
+ subject_kind=$subjectKind,
1629
+ subject_id=COALESCE($subjectId, subject_id),
1630
+ subject_path=COALESCE($subjectPath, subject_path),
1631
+ subject_url=COALESCE($subjectUrl, subject_url),
1632
+ subject_json=$subjectJson,
1633
+ intent=$intent,
1634
+ scope_json=COALESCE($scopeJson, scope_json),
1635
+ output_policy_json=COALESCE($outputPolicyJson, output_policy_json),
1636
+ updated_at=$updated
1637
+ WHERE source_kind=$sourceKind
1638
+ AND source_dedupe_key=$sourceDedupeKey
1639
+ AND EXISTS (
1640
+ SELECT 1
1641
+ FROM workflow_work_items
1642
+ WHERE id=$workItemId
1643
+ AND invocation_id=workflow_invocations.id
1644
+ AND status IN (${placeholders})
1645
+ )`).run({
1646
+ $workItemId: workItemId,
1647
+ $sourceKind: input.sourceRef.kind,
1648
+ $sourceDedupeKey: sourceDedupeKey,
1649
+ $workflowId: input.workflowId ?? null,
1650
+ $templateId: input.templateId ?? null,
1651
+ $sourceId: input.sourceRef.id ?? null,
1652
+ $sourceJson: JSON.stringify(input.sourceRef),
1653
+ $subjectKind: input.subjectRef.kind,
1654
+ $subjectId: input.subjectRef.id ?? null,
1655
+ $subjectPath: input.subjectRef.path ?? null,
1656
+ $subjectUrl: input.subjectRef.url ?? null,
1657
+ $subjectJson: JSON.stringify(input.subjectRef),
1658
+ $intent: input.intent,
1659
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
1660
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
1661
+ $updated: now,
1662
+ ...statusBindings
1663
+ });
1664
+ if (result.changes !== 1)
1665
+ throw new Error(`workflow work item is not refreshable: ${workItemId}`);
1666
+ const updated = this.db.query(`SELECT workflow_invocations.*
1667
+ FROM workflow_invocations
1668
+ JOIN workflow_work_items ON workflow_work_items.invocation_id = workflow_invocations.id
1669
+ WHERE workflow_work_items.id = ?`).get(workItemId);
1670
+ if (!updated)
1671
+ throw new Error(`workflow invocation not found after refresh for work item: ${workItemId}`);
1672
+ return rowToWorkflowInvocation(updated);
1673
+ }
1568
1674
  getWorkflowInvocation(id) {
1569
1675
  const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1570
1676
  return row ? rowToWorkflowInvocation(row) : undefined;
@@ -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;
package/dist/sdk/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),
@@ -1565,6 +1612,65 @@ class Store {
1565
1612
  throw new Error(`workflow invocation not found after create: ${id}`);
1566
1613
  return rowToWorkflowInvocation(row);
1567
1614
  }
1615
+ refreshWorkflowInvocationForWorkItem(workItemId, input) {
1616
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
1617
+ if (!sourceDedupeKey)
1618
+ throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1619
+ const now = nowIso();
1620
+ const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1621
+ const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1622
+ const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1623
+ const result = this.db.query(`UPDATE workflow_invocations
1624
+ SET workflow_id=COALESCE($workflowId, workflow_id),
1625
+ template_id=COALESCE($templateId, template_id),
1626
+ source_id=COALESCE($sourceId, source_id),
1627
+ source_json=$sourceJson,
1628
+ subject_kind=$subjectKind,
1629
+ subject_id=COALESCE($subjectId, subject_id),
1630
+ subject_path=COALESCE($subjectPath, subject_path),
1631
+ subject_url=COALESCE($subjectUrl, subject_url),
1632
+ subject_json=$subjectJson,
1633
+ intent=$intent,
1634
+ scope_json=COALESCE($scopeJson, scope_json),
1635
+ output_policy_json=COALESCE($outputPolicyJson, output_policy_json),
1636
+ updated_at=$updated
1637
+ WHERE source_kind=$sourceKind
1638
+ AND source_dedupe_key=$sourceDedupeKey
1639
+ AND EXISTS (
1640
+ SELECT 1
1641
+ FROM workflow_work_items
1642
+ WHERE id=$workItemId
1643
+ AND invocation_id=workflow_invocations.id
1644
+ AND status IN (${placeholders})
1645
+ )`).run({
1646
+ $workItemId: workItemId,
1647
+ $sourceKind: input.sourceRef.kind,
1648
+ $sourceDedupeKey: sourceDedupeKey,
1649
+ $workflowId: input.workflowId ?? null,
1650
+ $templateId: input.templateId ?? null,
1651
+ $sourceId: input.sourceRef.id ?? null,
1652
+ $sourceJson: JSON.stringify(input.sourceRef),
1653
+ $subjectKind: input.subjectRef.kind,
1654
+ $subjectId: input.subjectRef.id ?? null,
1655
+ $subjectPath: input.subjectRef.path ?? null,
1656
+ $subjectUrl: input.subjectRef.url ?? null,
1657
+ $subjectJson: JSON.stringify(input.subjectRef),
1658
+ $intent: input.intent,
1659
+ $scopeJson: input.scope ? JSON.stringify(input.scope) : null,
1660
+ $outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
1661
+ $updated: now,
1662
+ ...statusBindings
1663
+ });
1664
+ if (result.changes !== 1)
1665
+ throw new Error(`workflow work item is not refreshable: ${workItemId}`);
1666
+ const updated = this.db.query(`SELECT workflow_invocations.*
1667
+ FROM workflow_invocations
1668
+ JOIN workflow_work_items ON workflow_work_items.invocation_id = workflow_invocations.id
1669
+ WHERE workflow_work_items.id = ?`).get(workItemId);
1670
+ if (!updated)
1671
+ throw new Error(`workflow invocation not found after refresh for work item: ${workItemId}`);
1672
+ return rowToWorkflowInvocation(updated);
1673
+ }
1568
1674
  getWorkflowInvocation(id) {
1569
1675
  const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
1570
1676
  return row ? rowToWorkflowInvocation(row) : undefined;
package/dist/types.d.ts CHANGED
@@ -104,10 +104,13 @@ export interface AgentRoutingSpec {
104
104
  eventType?: string;
105
105
  eventSource?: string;
106
106
  }
107
- export interface AgentTarget {
107
+ export interface AgentPromptSource {
108
+ type: "file";
109
+ path: string;
110
+ }
111
+ export interface AgentTargetBase {
108
112
  type: "agent";
109
113
  provider: AgentProvider;
110
- prompt: string;
111
114
  cwd?: string;
112
115
  model?: string;
113
116
  variant?: string;
@@ -126,6 +129,13 @@ export interface AgentTarget {
126
129
  account?: AccountRef;
127
130
  preflight?: RuntimePreflightPolicy;
128
131
  }
132
+ export interface AgentTarget extends AgentTargetBase {
133
+ prompt: string;
134
+ promptSource?: AgentPromptSource;
135
+ }
136
+ export interface PromptFileAgentTarget extends AgentTargetBase {
137
+ promptFile: string;
138
+ }
129
139
  export interface WorkflowTarget {
130
140
  type: "workflow";
131
141
  workflowId: string;
@@ -134,7 +144,9 @@ export interface WorkflowTarget {
134
144
  preflight?: RuntimePreflightPolicy;
135
145
  }
136
146
  export type ExecutableTarget = CommandTarget | AgentTarget;
147
+ export type ExecutableTargetInput = CommandTarget | AgentTarget | PromptFileAgentTarget;
137
148
  export type LoopTarget = ExecutableTarget | WorkflowTarget;
149
+ export type LoopTargetInput = ExecutableTargetInput | WorkflowTarget;
138
150
  export type WorkflowStatus = "active" | "archived";
139
151
  export type WorkflowRunStatus = "running" | "succeeded" | "failed" | "timed_out" | "cancelled";
140
152
  export type WorkflowStepRunStatus = "pending" | "running" | "succeeded" | "failed" | "timed_out" | "skipped" | "cancelled";
@@ -233,6 +245,9 @@ export interface WorkflowStep {
233
245
  timeoutMs?: number;
234
246
  account?: AccountRef;
235
247
  }
248
+ export interface WorkflowStepInput extends Omit<WorkflowStep, "target"> {
249
+ target: ExecutableTargetInput;
250
+ }
236
251
  export interface WorkflowSpec {
237
252
  id: string;
238
253
  name: string;
@@ -248,7 +263,7 @@ export interface CreateWorkflowInput {
248
263
  name: string;
249
264
  description?: string;
250
265
  goal?: GoalSpec;
251
- steps: WorkflowStep[];
266
+ steps: WorkflowStepInput[];
252
267
  version?: number;
253
268
  }
254
269
  export type LoopTemplateKind = "workflow" | "loop";
@@ -367,7 +382,7 @@ export interface CreateLoopInput {
367
382
  name: string;
368
383
  description?: string;
369
384
  schedule: ScheduleSpec;
370
- target: LoopTarget;
385
+ target: LoopTargetInput;
371
386
  goal?: GoalSpec;
372
387
  machine?: LoopMachineRef;
373
388
  catchUp?: CatchUpPolicy;
package/docs/USAGE.md CHANGED
@@ -148,6 +148,56 @@ accounts tools add codewith --label "Codewith" --env-var CODEWITH_HOME --bin cod
148
148
  accounts tools add aicopilot --label "AI Copilot" --env-var AICOPILOT_CONFIG_DIR --bin aicopilot
149
149
  ```
150
150
 
151
+ ## Prompt Files
152
+
153
+ Use prompt files for production agent prompts instead of long inline strings.
154
+ This keeps the durable prompt source reviewable and prevents shell history from
155
+ becoming the only copy of the prompt.
156
+
157
+ ```bash
158
+ mkdir -p ~/.hasna/loops/prompts
159
+ $EDITOR ~/.hasna/loops/prompts/repo-morning-check.md
160
+
161
+ loops create agent morning-check \
162
+ --provider codewith \
163
+ --auth-profile account001 \
164
+ --cron "0 8 * * *" \
165
+ --cwd /path/to/repo \
166
+ --prompt-file ~/.hasna/loops/prompts/repo-morning-check.md
167
+ ```
168
+
169
+ Workflow JSON supports `promptFile` on agent targets. Relative paths resolve
170
+ from the workflow JSON file's directory:
171
+
172
+ ```json
173
+ {
174
+ "name": "repo-morning",
175
+ "steps": [
176
+ {
177
+ "id": "review",
178
+ "target": {
179
+ "type": "agent",
180
+ "provider": "codewith",
181
+ "cwd": "/path/to/repo",
182
+ "promptFile": "prompts/repo-morning-review.md"
183
+ }
184
+ }
185
+ ]
186
+ }
187
+ ```
188
+
189
+ OpenLoops stores the resolved prompt text for execution and records
190
+ `promptSource` metadata with the absolute file path. Public CLI output from
191
+ `show`, `list`, `workflows list`, `workflows show`, `workflows validate`,
192
+ `workflows create`, and `templates render` redacts prompt bodies by default
193
+ while keeping `promptSource` visible. Use
194
+ `~/.hasna/loops/prompts/<stable-name>.md` as the default prompt store for local
195
+ production loops.
196
+
197
+ Reusable custom templates cannot contain `promptFile`. Keep prompt files in
198
+ direct workflow JSON or agent loop creation; use template variables only for
199
+ non-secret routing/configuration data.
200
+
151
201
  ## Workflows
152
202
 
153
203
  Create a workflow JSON file:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.52",
3
+ "version": "0.3.54",
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",