@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/README.md +45 -0
- package/dist/cli/index.js +189 -51
- package/dist/daemon/index.js +118 -12
- package/dist/index.js +141 -20
- package/dist/lib/store.d.ts +1 -0
- package/dist/lib/store.js +114 -8
- package/dist/lib/workflow-spec.d.ts +8 -2
- package/dist/sdk/index.js +114 -8
- package/dist/types.d.ts +19 -4
- package/docs/USAGE.md +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,51 @@ accounts tools add codewith --label "Codewith" --env-var CODEWITH_HOME --bin cod
|
|
|
101
101
|
accounts tools add aicopilot --label "AI Copilot" --env-var AICOPILOT_CONFIG_DIR --bin aicopilot
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
## Prompt Files
|
|
105
|
+
|
|
106
|
+
Use prompt files for production agent prompts instead of long inline strings.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
mkdir -p ~/.hasna/loops/prompts
|
|
110
|
+
$EDITOR ~/.hasna/loops/prompts/repo-morning-check.md
|
|
111
|
+
|
|
112
|
+
loops create agent morning-check \
|
|
113
|
+
--provider codewith \
|
|
114
|
+
--auth-profile account001 \
|
|
115
|
+
--cron "0 8 * * *" \
|
|
116
|
+
--cwd /path/to/repo \
|
|
117
|
+
--prompt-file ~/.hasna/loops/prompts/repo-morning-check.md
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Workflow JSON also supports `promptFile` on agent targets. Relative paths
|
|
121
|
+
resolve from the workflow JSON file's directory:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"name": "repo-morning",
|
|
126
|
+
"steps": [
|
|
127
|
+
{
|
|
128
|
+
"id": "review",
|
|
129
|
+
"target": {
|
|
130
|
+
"type": "agent",
|
|
131
|
+
"provider": "codewith",
|
|
132
|
+
"cwd": "/path/to/repo",
|
|
133
|
+
"promptFile": "prompts/repo-morning-review.md"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
OpenLoops records `promptSource` metadata and redacts prompt bodies in public
|
|
141
|
+
CLI output by default, including `templates render`. Use
|
|
142
|
+
`~/.hasna/loops/prompts/<stable-name>.md` as the default prompt store for
|
|
143
|
+
production loops.
|
|
144
|
+
|
|
145
|
+
Reusable custom templates cannot contain `promptFile`. Keep prompt files in
|
|
146
|
+
direct workflow JSON or agent loop creation; use template variables only for
|
|
147
|
+
non-secret routing/configuration data.
|
|
148
|
+
|
|
104
149
|
## Goals
|
|
105
150
|
|
|
106
151
|
Add `--goal` to wrap a command, agent, or workflow loop in an AI-SDK orchestration layer. OpenLoops asks the configured model to create a flat DAG plan, executes ready nodes by calling the underlying target, then runs an adversarial achievement audit before marking the goal complete.
|
package/dist/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1263
|
+
target,
|
|
1217
1264
|
goal: input.goal,
|
|
1218
1265
|
machine: input.machine,
|
|
1219
1266
|
nextRunAt: initialNextRun(input.schedule, from),
|
|
@@ -1567,6 +1614,65 @@ class Store {
|
|
|
1567
1614
|
throw new Error(`workflow invocation not found after create: ${id}`);
|
|
1568
1615
|
return rowToWorkflowInvocation(row);
|
|
1569
1616
|
}
|
|
1617
|
+
refreshWorkflowInvocationForWorkItem(workItemId, input) {
|
|
1618
|
+
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
1619
|
+
if (!sourceDedupeKey)
|
|
1620
|
+
throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
|
|
1621
|
+
const now = nowIso();
|
|
1622
|
+
const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
|
|
1623
|
+
const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
|
|
1624
|
+
const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
|
|
1625
|
+
const result = this.db.query(`UPDATE workflow_invocations
|
|
1626
|
+
SET workflow_id=COALESCE($workflowId, workflow_id),
|
|
1627
|
+
template_id=COALESCE($templateId, template_id),
|
|
1628
|
+
source_id=COALESCE($sourceId, source_id),
|
|
1629
|
+
source_json=$sourceJson,
|
|
1630
|
+
subject_kind=$subjectKind,
|
|
1631
|
+
subject_id=COALESCE($subjectId, subject_id),
|
|
1632
|
+
subject_path=COALESCE($subjectPath, subject_path),
|
|
1633
|
+
subject_url=COALESCE($subjectUrl, subject_url),
|
|
1634
|
+
subject_json=$subjectJson,
|
|
1635
|
+
intent=$intent,
|
|
1636
|
+
scope_json=COALESCE($scopeJson, scope_json),
|
|
1637
|
+
output_policy_json=COALESCE($outputPolicyJson, output_policy_json),
|
|
1638
|
+
updated_at=$updated
|
|
1639
|
+
WHERE source_kind=$sourceKind
|
|
1640
|
+
AND source_dedupe_key=$sourceDedupeKey
|
|
1641
|
+
AND EXISTS (
|
|
1642
|
+
SELECT 1
|
|
1643
|
+
FROM workflow_work_items
|
|
1644
|
+
WHERE id=$workItemId
|
|
1645
|
+
AND invocation_id=workflow_invocations.id
|
|
1646
|
+
AND status IN (${placeholders})
|
|
1647
|
+
)`).run({
|
|
1648
|
+
$workItemId: workItemId,
|
|
1649
|
+
$sourceKind: input.sourceRef.kind,
|
|
1650
|
+
$sourceDedupeKey: sourceDedupeKey,
|
|
1651
|
+
$workflowId: input.workflowId ?? null,
|
|
1652
|
+
$templateId: input.templateId ?? null,
|
|
1653
|
+
$sourceId: input.sourceRef.id ?? null,
|
|
1654
|
+
$sourceJson: JSON.stringify(input.sourceRef),
|
|
1655
|
+
$subjectKind: input.subjectRef.kind,
|
|
1656
|
+
$subjectId: input.subjectRef.id ?? null,
|
|
1657
|
+
$subjectPath: input.subjectRef.path ?? null,
|
|
1658
|
+
$subjectUrl: input.subjectRef.url ?? null,
|
|
1659
|
+
$subjectJson: JSON.stringify(input.subjectRef),
|
|
1660
|
+
$intent: input.intent,
|
|
1661
|
+
$scopeJson: input.scope ? JSON.stringify(input.scope) : null,
|
|
1662
|
+
$outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
|
|
1663
|
+
$updated: now,
|
|
1664
|
+
...statusBindings
|
|
1665
|
+
});
|
|
1666
|
+
if (result.changes !== 1)
|
|
1667
|
+
throw new Error(`workflow work item is not refreshable: ${workItemId}`);
|
|
1668
|
+
const updated = this.db.query(`SELECT workflow_invocations.*
|
|
1669
|
+
FROM workflow_invocations
|
|
1670
|
+
JOIN workflow_work_items ON workflow_work_items.invocation_id = workflow_invocations.id
|
|
1671
|
+
WHERE workflow_work_items.id = ?`).get(workItemId);
|
|
1672
|
+
if (!updated)
|
|
1673
|
+
throw new Error(`workflow invocation not found after refresh for work item: ${workItemId}`);
|
|
1674
|
+
return rowToWorkflowInvocation(updated);
|
|
1675
|
+
}
|
|
1570
1676
|
getWorkflowInvocation(id) {
|
|
1571
1677
|
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1572
1678
|
return row ? rowToWorkflowInvocation(row) : undefined;
|
|
@@ -2855,9 +2961,9 @@ class Store {
|
|
|
2855
2961
|
|
|
2856
2962
|
// src/cli/index.ts
|
|
2857
2963
|
import { createHash as createHash3, randomUUID } from "crypto";
|
|
2858
|
-
import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as
|
|
2964
|
+
import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync4, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
2859
2965
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
2860
|
-
import { join as join6, resolve as
|
|
2966
|
+
import { dirname as dirname4, join as join6, resolve as resolve3 } from "path";
|
|
2861
2967
|
import { tmpdir as tmpdir2 } from "os";
|
|
2862
2968
|
import { Database as Database2 } from "bun:sqlite";
|
|
2863
2969
|
import { Command } from "commander";
|
|
@@ -4972,13 +5078,13 @@ async function tick(deps) {
|
|
|
4972
5078
|
}
|
|
4973
5079
|
|
|
4974
5080
|
// src/daemon/control.ts
|
|
4975
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
5081
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4976
5082
|
import { hostname } from "os";
|
|
4977
5083
|
import { dirname as dirname2 } from "path";
|
|
4978
5084
|
|
|
4979
5085
|
// src/daemon/loop.ts
|
|
4980
5086
|
function realSleep(ms) {
|
|
4981
|
-
return new Promise((
|
|
5087
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
4982
5088
|
}
|
|
4983
5089
|
async function runLoop(opts) {
|
|
4984
5090
|
const sleep = opts.sleep ?? realSleep;
|
|
@@ -5003,7 +5109,7 @@ function readPid(path = pidFilePath()) {
|
|
|
5003
5109
|
if (!existsSync3(path))
|
|
5004
5110
|
return;
|
|
5005
5111
|
try {
|
|
5006
|
-
const pid = Number(
|
|
5112
|
+
const pid = Number(readFileSync2(path, "utf8").trim());
|
|
5007
5113
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
5008
5114
|
} catch {
|
|
5009
5115
|
return;
|
|
@@ -5908,7 +6014,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5908
6014
|
// package.json
|
|
5909
6015
|
var package_default = {
|
|
5910
6016
|
name: "@hasna/loops",
|
|
5911
|
-
version: "0.3.
|
|
6017
|
+
version: "0.3.54",
|
|
5912
6018
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5913
6019
|
type: "module",
|
|
5914
6020
|
main: "dist/index.js",
|
|
@@ -5998,9 +6104,9 @@ function packageVersion() {
|
|
|
5998
6104
|
|
|
5999
6105
|
// src/lib/templates.ts
|
|
6000
6106
|
import { execFileSync } from "child_process";
|
|
6001
|
-
import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as
|
|
6107
|
+
import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
6002
6108
|
import { homedir as homedir3 } from "os";
|
|
6003
|
-
import { basename as basename3, isAbsolute, join as join5, relative, resolve } from "path";
|
|
6109
|
+
import { basename as basename3, isAbsolute as isAbsolute2, join as join5, relative, resolve as resolve2 } from "path";
|
|
6004
6110
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
6005
6111
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
6006
6112
|
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
@@ -6282,7 +6388,7 @@ function normalizeWorktreeMode(mode) {
|
|
|
6282
6388
|
function defaultWorktreeRoot(root) {
|
|
6283
6389
|
if (root?.trim()) {
|
|
6284
6390
|
const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
|
|
6285
|
-
return
|
|
6391
|
+
return isAbsolute2(expanded) ? expanded : resolve2(expanded);
|
|
6286
6392
|
}
|
|
6287
6393
|
return join5(homedir3(), ".hasna", "loops", "worktrees");
|
|
6288
6394
|
}
|
|
@@ -6389,7 +6495,7 @@ function worktreePlan(input, seed) {
|
|
|
6389
6495
|
const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
|
|
6390
6496
|
const worktreePath = join5(root, repoSlug, seedSlug);
|
|
6391
6497
|
const relativeCwd = relative(repoRoot, originalCwd);
|
|
6392
|
-
const cwd = relativeCwd && !relativeCwd.startsWith("..") && !
|
|
6498
|
+
const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute2(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
|
|
6393
6499
|
const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
|
|
6394
6500
|
const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
|
|
6395
6501
|
const prepareStep = {
|
|
@@ -6624,9 +6730,24 @@ function assertNoImplicitDangerFullAccess(value, label) {
|
|
|
6624
6730
|
assertNoImplicitDangerFullAccess(entry, `${label}.${key}`);
|
|
6625
6731
|
}
|
|
6626
6732
|
}
|
|
6733
|
+
function assertNoCustomTemplatePromptFiles(value, label) {
|
|
6734
|
+
if (!value || typeof value !== "object")
|
|
6735
|
+
return;
|
|
6736
|
+
if (Array.isArray(value)) {
|
|
6737
|
+
value.forEach((entry, index) => assertNoCustomTemplatePromptFiles(entry, `${label}[${index}]`));
|
|
6738
|
+
return;
|
|
6739
|
+
}
|
|
6740
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
6741
|
+
if (key === "promptFile") {
|
|
6742
|
+
throw new Error(`${label}.${key} is not allowed in custom templates; use direct workflow JSON for prompt-file-backed workflows`);
|
|
6743
|
+
}
|
|
6744
|
+
assertNoCustomTemplatePromptFiles(entry, `${label}.${key}`);
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6627
6747
|
function assertCustomTemplateSafety(value, label) {
|
|
6628
6748
|
assertNoDangerousCustomTemplateScalars(value, label);
|
|
6629
6749
|
assertNoImplicitDangerFullAccess(value, label);
|
|
6750
|
+
assertNoCustomTemplatePromptFiles(value, label);
|
|
6630
6751
|
}
|
|
6631
6752
|
function customTemplateDefinitionFromJson(value, sourcePath) {
|
|
6632
6753
|
assertRecord(value, sourcePath);
|
|
@@ -6656,7 +6777,7 @@ function customTemplateSummary(definition, sourcePath) {
|
|
|
6656
6777
|
function readCustomTemplateFile(file) {
|
|
6657
6778
|
let parsed;
|
|
6658
6779
|
try {
|
|
6659
|
-
parsed = JSON.parse(
|
|
6780
|
+
parsed = JSON.parse(readFileSync3(file, "utf8"));
|
|
6660
6781
|
} catch (error) {
|
|
6661
6782
|
const message = error instanceof Error ? error.message : String(error);
|
|
6662
6783
|
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
@@ -6795,19 +6916,19 @@ function renderCustomLoopTemplate(entry, values) {
|
|
|
6795
6916
|
return workflow;
|
|
6796
6917
|
}
|
|
6797
6918
|
function validateCustomLoopTemplateFile(file) {
|
|
6798
|
-
const source =
|
|
6919
|
+
const source = resolve2(file);
|
|
6799
6920
|
const entry = readCustomTemplateFile(source);
|
|
6800
|
-
const existing = loadCustomLoopTemplatesRaw().filter((template) =>
|
|
6921
|
+
const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== source);
|
|
6801
6922
|
assertNoTemplateCollisions([...existing, entry]);
|
|
6802
6923
|
return structuredClone(entry.summary);
|
|
6803
6924
|
}
|
|
6804
6925
|
function importCustomLoopTemplate(file, opts = {}) {
|
|
6805
|
-
const source =
|
|
6926
|
+
const source = resolve2(file);
|
|
6806
6927
|
const entry = readCustomTemplateFile(source);
|
|
6807
6928
|
const dir = ensureCustomLoopTemplatesDir();
|
|
6808
6929
|
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
6809
6930
|
const replaced = existsSync4(destination);
|
|
6810
|
-
const existing = loadCustomLoopTemplatesRaw().filter((template) =>
|
|
6931
|
+
const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== resolve2(destination));
|
|
6811
6932
|
assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
|
|
6812
6933
|
if (replaced) {
|
|
6813
6934
|
const stat = lstatSync(destination);
|
|
@@ -7613,12 +7734,13 @@ function validationFailed(error, context) {
|
|
|
7613
7734
|
});
|
|
7614
7735
|
process.exit(1);
|
|
7615
7736
|
}
|
|
7616
|
-
function
|
|
7737
|
+
function normalizeLoopTargetForStorage(target, context, opts = {}) {
|
|
7617
7738
|
try {
|
|
7618
|
-
workflowBodyFromJson({
|
|
7739
|
+
const workflow = workflowBodyFromJson({
|
|
7619
7740
|
name: "loop-target-validation",
|
|
7620
7741
|
steps: [{ id: "target", target }]
|
|
7621
|
-
});
|
|
7742
|
+
}, undefined, opts);
|
|
7743
|
+
return workflow.steps[0].target;
|
|
7622
7744
|
} catch (error) {
|
|
7623
7745
|
validationFailed(error, context);
|
|
7624
7746
|
}
|
|
@@ -7630,6 +7752,14 @@ function normalizeWorkflowForStorage(body, context) {
|
|
|
7630
7752
|
validationFailed(error, context);
|
|
7631
7753
|
}
|
|
7632
7754
|
}
|
|
7755
|
+
function workflowBodyFromFile(file, fallbackName, context) {
|
|
7756
|
+
try {
|
|
7757
|
+
const resolved = resolve3(file);
|
|
7758
|
+
return workflowBodyFromJson(JSON.parse(readFileSync4(resolved, "utf8")), fallbackName, { baseDir: dirname4(resolved) });
|
|
7759
|
+
} catch (error) {
|
|
7760
|
+
validationFailed(error, context);
|
|
7761
|
+
}
|
|
7762
|
+
}
|
|
7633
7763
|
function preflightLoopTarget(target, context, metadata, opts) {
|
|
7634
7764
|
try {
|
|
7635
7765
|
return preflightTarget(target, metadata, opts);
|
|
@@ -7658,6 +7788,11 @@ function workflowSpecForPreflight(body, id = "validation") {
|
|
|
7658
7788
|
updatedAt: now
|
|
7659
7789
|
};
|
|
7660
7790
|
}
|
|
7791
|
+
function publicWorkflowBody(body) {
|
|
7792
|
+
const value = publicWorkflow(workflowSpecForPreflight(body, "render"));
|
|
7793
|
+
const { id: _id, status: _status, createdAt: _createdAt, updatedAt: _updatedAt, ...bodyOnly } = value;
|
|
7794
|
+
return bodyOnly;
|
|
7795
|
+
}
|
|
7661
7796
|
function printTextOutput(value) {
|
|
7662
7797
|
for (const line of textOutputBlocks(value, { indent: " " }))
|
|
7663
7798
|
console.log(line);
|
|
@@ -7854,7 +7989,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
|
7854
7989
|
return {
|
|
7855
7990
|
ok: result.status === 0,
|
|
7856
7991
|
status: result.status,
|
|
7857
|
-
stdout:
|
|
7992
|
+
stdout: readFileSync4(stdoutPath, "utf8"),
|
|
7858
7993
|
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
|
|
7859
7994
|
error: result.error ? String(result.error.message || result.error) : ""
|
|
7860
7995
|
};
|
|
@@ -7898,7 +8033,7 @@ function readRouteCursors() {
|
|
|
7898
8033
|
if (!existsSync5(path))
|
|
7899
8034
|
return {};
|
|
7900
8035
|
try {
|
|
7901
|
-
const parsed = JSON.parse(
|
|
8036
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
7902
8037
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
7903
8038
|
} catch {
|
|
7904
8039
|
return {};
|
|
@@ -8253,7 +8388,7 @@ function hasThrottleLimits(limits) {
|
|
|
8253
8388
|
function normalizeRoutePath(value) {
|
|
8254
8389
|
if (!value?.trim())
|
|
8255
8390
|
return;
|
|
8256
|
-
const resolved =
|
|
8391
|
+
const resolved = resolve3(value.trim());
|
|
8257
8392
|
let canonical = resolved;
|
|
8258
8393
|
try {
|
|
8259
8394
|
canonical = realpathSync(resolved);
|
|
@@ -8265,7 +8400,7 @@ function normalizeRoutePath(value) {
|
|
|
8265
8400
|
try {
|
|
8266
8401
|
return realpathSync(gitRoot.stdout.trim());
|
|
8267
8402
|
} catch {
|
|
8268
|
-
return
|
|
8403
|
+
return resolve3(gitRoot.stdout.trim());
|
|
8269
8404
|
}
|
|
8270
8405
|
}
|
|
8271
8406
|
return canonical;
|
|
@@ -8274,7 +8409,7 @@ function routeProjectGroup(optsGroup, data, metadata) {
|
|
|
8274
8409
|
return optsGroup?.trim() || taskEventField(data, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]) || taskEventField(metadata, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]);
|
|
8275
8410
|
}
|
|
8276
8411
|
function routeThrottleDecision(store, args) {
|
|
8277
|
-
const projectPath = normalizeRoutePath(args.projectPath) ??
|
|
8412
|
+
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve3(args.projectPath);
|
|
8278
8413
|
const projectGroup = args.projectGroup?.trim() || undefined;
|
|
8279
8414
|
const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup });
|
|
8280
8415
|
const base = {
|
|
@@ -8299,7 +8434,7 @@ function routeThrottleDecision(store, args) {
|
|
|
8299
8434
|
return { ...base, allowed: true };
|
|
8300
8435
|
}
|
|
8301
8436
|
function routeThrottleDryRunPreview(args) {
|
|
8302
|
-
const projectPath = normalizeRoutePath(args.projectPath) ??
|
|
8437
|
+
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve3(args.projectPath);
|
|
8303
8438
|
const projectGroup = args.projectGroup?.trim() || undefined;
|
|
8304
8439
|
return {
|
|
8305
8440
|
evaluated: false,
|
|
@@ -8321,7 +8456,7 @@ function todosTaskRouteTemplateId(opts) {
|
|
|
8321
8456
|
return id;
|
|
8322
8457
|
}
|
|
8323
8458
|
async function readEventEnvelopeInput(opts = {}) {
|
|
8324
|
-
const raw = opts.eventJson ?? (opts.eventFile ?
|
|
8459
|
+
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync4(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
8325
8460
|
const event = JSON.parse(raw);
|
|
8326
8461
|
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
8327
8462
|
throw new Error("event JSON must be an object");
|
|
@@ -8362,7 +8497,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8362
8497
|
"cwd"
|
|
8363
8498
|
]);
|
|
8364
8499
|
const projectPath = dataProjectPath ?? metadataProjectPath ?? opts.projectPath ?? process.cwd();
|
|
8365
|
-
const routeProjectPath = normalizeRoutePath(projectPath) ??
|
|
8500
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve3(projectPath);
|
|
8366
8501
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
8367
8502
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
8368
8503
|
const idempotencyKey = `todos-task:${taskId}`;
|
|
@@ -8536,8 +8671,9 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8536
8671
|
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
8537
8672
|
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
8538
8673
|
});
|
|
8674
|
+
const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
|
|
8539
8675
|
if (throttle && !throttle.allowed)
|
|
8540
|
-
return { kind: "throttled", invocation, workItem, throttle };
|
|
8676
|
+
return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
|
|
8541
8677
|
const workflow = routeWorkflowForStorage(store, workflowBody);
|
|
8542
8678
|
const loop = store.createLoop({
|
|
8543
8679
|
...loopInput,
|
|
@@ -8545,13 +8681,13 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8545
8681
|
type: "workflow",
|
|
8546
8682
|
workflowId: workflow.id,
|
|
8547
8683
|
input: {
|
|
8548
|
-
workflowInvocationId:
|
|
8684
|
+
workflowInvocationId: refreshedInvocation.id,
|
|
8549
8685
|
workflowWorkItemId: workItem.id
|
|
8550
8686
|
}
|
|
8551
8687
|
}
|
|
8552
8688
|
});
|
|
8553
8689
|
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by todos-task route" });
|
|
8554
|
-
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
8690
|
+
return { kind: "created", invocation: refreshedInvocation, workItem: admitted, workflow, loop, throttle };
|
|
8555
8691
|
});
|
|
8556
8692
|
if (outcome.kind === "deduped") {
|
|
8557
8693
|
return {
|
|
@@ -8611,7 +8747,7 @@ function routeGenericEvent(event, opts) {
|
|
|
8611
8747
|
const data = eventData(event);
|
|
8612
8748
|
const metadata = eventMetadata(event);
|
|
8613
8749
|
const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "project_path", "projectPath", "project_canonical_path", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
|
|
8614
|
-
const routeProjectPath = normalizeRoutePath(projectPath) ??
|
|
8750
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve3(projectPath);
|
|
8615
8751
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
8616
8752
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
8617
8753
|
const eventSuffix = event.id.slice(0, 8);
|
|
@@ -8752,8 +8888,9 @@ function routeGenericEvent(event, opts) {
|
|
|
8752
8888
|
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
8753
8889
|
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
8754
8890
|
});
|
|
8891
|
+
const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
|
|
8755
8892
|
if (throttle && !throttle.allowed)
|
|
8756
|
-
return { kind: "throttled", invocation, workItem, throttle };
|
|
8893
|
+
return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
|
|
8757
8894
|
const workflow = routeWorkflowForStorage(store, workflowBody);
|
|
8758
8895
|
const loop = store.createLoop({
|
|
8759
8896
|
...loopInput,
|
|
@@ -8761,13 +8898,13 @@ function routeGenericEvent(event, opts) {
|
|
|
8761
8898
|
type: "workflow",
|
|
8762
8899
|
workflowId: workflow.id,
|
|
8763
8900
|
input: {
|
|
8764
|
-
workflowInvocationId:
|
|
8901
|
+
workflowInvocationId: refreshedInvocation.id,
|
|
8765
8902
|
workflowWorkItemId: workItem.id
|
|
8766
8903
|
}
|
|
8767
8904
|
}
|
|
8768
8905
|
});
|
|
8769
8906
|
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
|
|
8770
|
-
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
8907
|
+
return { kind: "created", invocation: refreshedInvocation, workItem: admitted, workflow, loop, throttle };
|
|
8771
8908
|
});
|
|
8772
8909
|
if (outcome.kind === "deduped") {
|
|
8773
8910
|
return {
|
|
@@ -8940,8 +9077,8 @@ function taskMatchesDrainFilters(task, filters) {
|
|
|
8940
9077
|
const path = taskProjectPath(task);
|
|
8941
9078
|
if (!path)
|
|
8942
9079
|
return false;
|
|
8943
|
-
const normalizedPath = normalizeRoutePath(path) ??
|
|
8944
|
-
const normalizedPrefix = normalizeRoutePath(filters.projectPathPrefix) ??
|
|
9080
|
+
const normalizedPath = normalizeRoutePath(path) ?? resolve3(path);
|
|
9081
|
+
const normalizedPrefix = normalizeRoutePath(filters.projectPathPrefix) ?? resolve3(filters.projectPathPrefix);
|
|
8945
9082
|
if (normalizedPath !== normalizedPrefix && !normalizedPath.startsWith(`${normalizedPrefix}/`))
|
|
8946
9083
|
return false;
|
|
8947
9084
|
}
|
|
@@ -9016,7 +9153,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
|
|
|
9016
9153
|
store.close();
|
|
9017
9154
|
}
|
|
9018
9155
|
});
|
|
9019
|
-
addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").
|
|
9156
|
+
addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").option("--prompt <prompt>", "agent prompt").option("--prompt-file <file>", "read the agent prompt from a markdown/text file").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe").option("--preflight-each-run", "check provider/account readiness before every scheduled run").option("--preflight", "check target executables/accounts before storing the loop"))))).action((name, opts) => {
|
|
9020
9157
|
const provider = opts.provider;
|
|
9021
9158
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
|
|
9022
9159
|
throw new Error("unsupported provider");
|
|
@@ -9026,10 +9163,11 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
|
|
|
9026
9163
|
}
|
|
9027
9164
|
const store = new Store;
|
|
9028
9165
|
try {
|
|
9029
|
-
const target = {
|
|
9166
|
+
const target = normalizeLoopTargetForStorage({
|
|
9030
9167
|
type: "agent",
|
|
9031
9168
|
provider,
|
|
9032
9169
|
prompt: opts.prompt,
|
|
9170
|
+
promptFile: opts.promptFile,
|
|
9033
9171
|
cwd: opts.cwd,
|
|
9034
9172
|
model: opts.model,
|
|
9035
9173
|
variant: opts.variant,
|
|
@@ -9043,9 +9181,8 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
|
|
|
9043
9181
|
allowlist: allowlistFromOpts(opts),
|
|
9044
9182
|
account: accountFromOpts(opts),
|
|
9045
9183
|
preflight: runtimePreflightFromOpts(opts)
|
|
9046
|
-
};
|
|
9184
|
+
}, { name, type: "agent", provider }, { baseDir: process.cwd() });
|
|
9047
9185
|
const input = baseCreateInput(name, opts, target);
|
|
9048
|
-
validateLoopTargetForStorage(input.target, { name, type: "agent", provider });
|
|
9049
9186
|
const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "agent", provider }, { loopName: name }, { machine: input.machine }) : undefined;
|
|
9050
9187
|
const loop = store.createLoop(input);
|
|
9051
9188
|
printCreatedLoop(loop, `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
|
|
@@ -9339,7 +9476,8 @@ addTemplateSourceOption(templates.command("show <id>").description("show a templ
|
|
|
9339
9476
|
});
|
|
9340
9477
|
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) => {
|
|
9341
9478
|
const workflow = renderLoopTemplate(id, parseVars(opts.var), { source: templateSource(opts.source) });
|
|
9342
|
-
|
|
9479
|
+
const value = publicWorkflowBody(workflow);
|
|
9480
|
+
print(value, JSON.stringify(value, null, 2));
|
|
9343
9481
|
});
|
|
9344
9482
|
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) => {
|
|
9345
9483
|
const store = new Store;
|
|
@@ -9516,7 +9654,7 @@ machines.command("show <id>").description("resolve a machine assignment").action
|
|
|
9516
9654
|
print(resolveLoopMachine(id));
|
|
9517
9655
|
});
|
|
9518
9656
|
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) => {
|
|
9519
|
-
const body =
|
|
9657
|
+
const body = workflowBodyFromFile(file, opts.name, { file, type: "workflow" });
|
|
9520
9658
|
const workflow = workflowSpecForPreflight(body);
|
|
9521
9659
|
const preflight = opts.preflight ? preflightWorkflow(workflow) : undefined;
|
|
9522
9660
|
print({ valid: true, workflow: publicWorkflow(workflow), preflight }, `valid workflow ${workflow.name} steps=${workflow.steps.length}`);
|
|
@@ -9524,7 +9662,7 @@ workflows.command("validate <file>").description("validate a workflow JSON file
|
|
|
9524
9662
|
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) => {
|
|
9525
9663
|
const store = new Store;
|
|
9526
9664
|
try {
|
|
9527
|
-
const body =
|
|
9665
|
+
const body = workflowBodyFromFile(file, opts.name, { file, type: "workflow" });
|
|
9528
9666
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(body, "creation-preflight"), { name: body.name, type: "workflow" }, {}) : undefined;
|
|
9529
9667
|
const workflow = store.createWorkflow(body);
|
|
9530
9668
|
if (preflight !== undefined)
|
|
@@ -10333,7 +10471,7 @@ daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) =>
|
|
|
10333
10471
|
console.log("");
|
|
10334
10472
|
return;
|
|
10335
10473
|
}
|
|
10336
|
-
const lines =
|
|
10474
|
+
const lines = readFileSync4(path, "utf8").trimEnd().split(`
|
|
10337
10475
|
`);
|
|
10338
10476
|
console.log(lines.slice(-Number(opts.lines)).join(`
|
|
10339
10477
|
`));
|