agenticloops 0.1.1 → 0.2.0
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/bin/cli.mjs +34 -1
- package/package.json +2 -1
- package/src/backends/fivedive.mjs +91 -0
- package/src/backends/portable.mjs +45 -0
- package/src/chain.mjs +72 -0
- package/src/config.mjs +1 -1
- package/src/harness.mjs +43 -2
- package/src/install.mjs +21 -4
- package/src/loop.mjs +43 -0
- package/src/receipt.mjs +84 -0
- package/src/runcmd.mjs +74 -0
- package/src/schedule.mjs +3 -2
package/bin/cli.mjs
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// agenticloops — install and run agentic loops (recurring AI agents in one
|
|
3
3
|
// LOOP.md). One file defines it; any harness can run it. https://agenticloops.dev
|
|
4
4
|
import { install } from "../src/install.mjs";
|
|
5
|
+
import { runLoop } from "../src/runcmd.mjs";
|
|
5
6
|
import { loadRegistry, search } from "../src/registry.mjs";
|
|
6
7
|
import { listRecords, parseSchedule } from "../src/schedule.mjs";
|
|
7
8
|
import { fetchInstalls } from "../src/telemetry.mjs";
|
|
8
9
|
import { c, sym, fail, info, CliError } from "../src/util.mjs";
|
|
9
10
|
|
|
10
|
-
const VERSION = "0.1.
|
|
11
|
+
const VERSION = "0.1.2";
|
|
11
12
|
|
|
12
13
|
function parseArgs(argv) {
|
|
13
14
|
const flags = {};
|
|
@@ -25,6 +26,7 @@ const HELP = `${c.bold("agenticloops")} — install and run agentic loops ${c.di
|
|
|
25
26
|
|
|
26
27
|
${c.bold("Usage")}
|
|
27
28
|
npx agenticloops install <owner/loop> [--harness=<id>] [--no-telemetry] [--yes] [--dry-run]
|
|
29
|
+
npx agenticloops run <owner/loop|path> [--harness=<id>] [--backend=portable|5dive]
|
|
28
30
|
npx agenticloops find <query>
|
|
29
31
|
npx agenticloops list
|
|
30
32
|
npx agenticloops update [<slug>]
|
|
@@ -32,6 +34,8 @@ ${c.bold("Usage")}
|
|
|
32
34
|
${c.bold("Commands")}
|
|
33
35
|
install Fetch + validate a LOOP.md, install its skills, pre-flight its
|
|
34
36
|
requirements, and register the recurring job on your harness.
|
|
37
|
+
run Execute a loop's chain once now (what a schedule fires). Multi-agent
|
|
38
|
+
loops run each role in order, threading {{previous_output}} forward.
|
|
35
39
|
find Search the public directory (agenticloops.dev) for loops.
|
|
36
40
|
list Show loops installed on this machine + their install counts.
|
|
37
41
|
update Re-fetch + re-install a loop (all, or one slug).
|
|
@@ -57,9 +61,32 @@ async function cmdInstall(positional, flags) {
|
|
|
57
61
|
noTelemetry: !!flags["no-telemetry"],
|
|
58
62
|
yes: !!flags.yes,
|
|
59
63
|
dryRun: !!flags["dry-run"],
|
|
64
|
+
run: !!flags.run, // --run: execute the chain once right after install
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
async function cmdRun(positional, flags) {
|
|
69
|
+
const ref = positional[0];
|
|
70
|
+
if (!ref) throw new CliError("usage: agenticloops run <owner/loop|path>", 2);
|
|
71
|
+
// --agents=role:agent,role:agent maps chain roles to 5dive agents (5dive backend).
|
|
72
|
+
let roleAgents;
|
|
73
|
+
if (typeof flags.agents === "string") {
|
|
74
|
+
roleAgents = {};
|
|
75
|
+
for (const pair of flags.agents.split(",")) {
|
|
76
|
+
const [role, agent] = pair.split(":");
|
|
77
|
+
if (role && agent) roleAgents[role.trim()] = agent.trim();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const res = await runLoop(ref, {
|
|
81
|
+
harness: typeof flags.harness === "string" ? flags.harness : undefined,
|
|
82
|
+
backend: typeof flags.backend === "string" ? flags.backend : undefined,
|
|
83
|
+
roleAgents,
|
|
84
|
+
agent: typeof flags.agent === "string" ? flags.agent : undefined,
|
|
85
|
+
});
|
|
86
|
+
if (flags.json) process.stdout.write(JSON.stringify(res.signed || res.receipt, null, 2) + "\n");
|
|
87
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
async function cmdFind(positional) {
|
|
64
91
|
const query = positional.join(" ");
|
|
65
92
|
const { loops, url } = await loadRegistry();
|
|
@@ -94,8 +121,12 @@ async function cmdList() {
|
|
|
94
121
|
for (const r of recs) {
|
|
95
122
|
const n = installs[r.slug];
|
|
96
123
|
const sched = r.cron ? r.cron : r.trigger?.value || "?";
|
|
124
|
+
const kind = r.multiAgent
|
|
125
|
+
? c.cyan(`multi-agent: ${(r.roles || []).join(" → ")}`)
|
|
126
|
+
: c.dim("single-agent");
|
|
97
127
|
process.stdout.write(
|
|
98
128
|
`${c.bold(r.slug)} ${c.dim("on " + r.harness)}${typeof n === "number" ? c.dim(" " + n + "↓ globally") : ""}\n` +
|
|
129
|
+
` ${kind}\n` +
|
|
99
130
|
` ${c.cyan(sched)} — ${(r.description || "").split("\n")[0]}\n` +
|
|
100
131
|
` ${c.dim(r.source || r.ref)}\n\n`,
|
|
101
132
|
);
|
|
@@ -138,6 +169,8 @@ async function main() {
|
|
|
138
169
|
case "install":
|
|
139
170
|
case "add":
|
|
140
171
|
return cmdInstall(positional, flags);
|
|
172
|
+
case "run":
|
|
173
|
+
return cmdRun(positional, flags);
|
|
141
174
|
case "find":
|
|
142
175
|
case "search":
|
|
143
176
|
return cmdFind(positional, flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agenticloops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Install and run agentic loops — recurring AI agents defined in a single LOOP.md file. One file defines it; any harness can run it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"dependencies": {
|
|
38
|
+
"@5dive/openagent": "^0.35.0",
|
|
38
39
|
"yaml": "^2.4.5"
|
|
39
40
|
}
|
|
40
41
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// 5dive-native backend — the host-optimized path. Each role is a linked 5dive
|
|
2
|
+
// task assigned to a role agent; the agent is woken via HEARTBEAT (deterministic
|
|
3
|
+
// — not tmux/agent-send keystroke injection, which is racy to a busy agent and
|
|
4
|
+
// needs sudo), does the work, and finishes with `task done --result=…`. The
|
|
5
|
+
// driver polls task STATUS (cold-safe) and the engine threads the structured
|
|
6
|
+
// result into the next role's {{previous_output}}.
|
|
7
|
+
//
|
|
8
|
+
// Role→agent: opts.roleAgents maps role id -> agent name; falls back to
|
|
9
|
+
// opts.agent (one agent runs the roles in sequence). v0.2 provisions a distinct
|
|
10
|
+
// keyed agent per role so each handoff becomes a co-signed edge.
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
const sh = (args) => spawnSync("5dive", args, { encoding: "utf8" });
|
|
14
|
+
|
|
15
|
+
function taskAdd({ title, body, assignee }) {
|
|
16
|
+
// NOTE: 5dive flags are =form — `--body=x`, not `--body x` (caught in spike).
|
|
17
|
+
const r = sh(["task", "add", title, `--body=${body}`, `--assignee=${assignee}`, "--priority=high", "--json"]);
|
|
18
|
+
if (r.status !== 0) throw new Error(`task add failed: ${(r.stderr || r.stdout || "").trim()}`);
|
|
19
|
+
const m = (r.stdout || "").match(/([A-Z]+-\d+)/);
|
|
20
|
+
if (!m) throw new Error(`could not parse task id from: ${r.stdout}`);
|
|
21
|
+
return m[1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureHeartbeat(agent) {
|
|
25
|
+
// Idempotent: enroll the role agent so a queued task wakes it. Best-effort —
|
|
26
|
+
// if heartbeat enroll fails we still created the task (an operator/other tick
|
|
27
|
+
// can pick it up); we just won't auto-drive.
|
|
28
|
+
sh(["heartbeat", "on", agent]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nudgeTick() {
|
|
32
|
+
// Wake due agents NOW instead of waiting for the heartbeat cron, so a
|
|
33
|
+
// run-now chain doesn't stall up to the tick interval. Best-effort; needs root.
|
|
34
|
+
spawnSync("sudo", ["5dive", "heartbeat", "tick"], { encoding: "utf8" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The handoff instruction the heartbeat-woken agent needs: it sees ONLY the task
|
|
38
|
+
// body (no agent-send), so the body must tell it to finish by completing the
|
|
39
|
+
// task with its full output as --result (that result IS the next role's input).
|
|
40
|
+
function withHandoffInstruction(prompt) {
|
|
41
|
+
return (
|
|
42
|
+
`${prompt}\n\n` +
|
|
43
|
+
`--- how to finish (required) ---\n` +
|
|
44
|
+
`Complete THIS task with your full, self-contained output as the result:\n` +
|
|
45
|
+
` 5dive task done <this-task-id> --result="<your output>"\n` +
|
|
46
|
+
`Your result is the ONLY thing passed to the next role — make it complete.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pollTask(taskId) {
|
|
51
|
+
const r = sh(["task", "show", taskId, "--json"]);
|
|
52
|
+
if (r.status !== 0) return { status: "unknown" };
|
|
53
|
+
try {
|
|
54
|
+
const j = JSON.parse(r.stdout);
|
|
55
|
+
const d = j.data?.task || j.data || j; // task show nests under data.task
|
|
56
|
+
return { status: d.status, result: d.result };
|
|
57
|
+
} catch {
|
|
58
|
+
return { status: "unknown" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fivediveBackend(opts = {}) {
|
|
63
|
+
const roleAgents = opts.roleAgents || {};
|
|
64
|
+
const fallback = opts.agent || null;
|
|
65
|
+
const pollEveryMs = opts.pollEveryMs || 10000;
|
|
66
|
+
const timeoutMs = opts.timeoutMs || 30 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
label: "5dive (linked tasks · heartbeat wake · structured result)",
|
|
70
|
+
async runRole({ role, prompt, onLog }) {
|
|
71
|
+
const agent = roleAgents[role] || fallback;
|
|
72
|
+
if (!agent) {
|
|
73
|
+
return { status: "error", output: "", error: `no agent mapped for role "${role}" (set roleAgents or --agent)` };
|
|
74
|
+
}
|
|
75
|
+
const taskId = taskAdd({ title: `${opts.loop || "loop"}:${role}`, body: withHandoffInstruction(prompt), assignee: agent });
|
|
76
|
+
ensureHeartbeat(agent);
|
|
77
|
+
if (opts.nudge !== false) nudgeTick();
|
|
78
|
+
onLog?.(`task ${taskId} → ${agent} (heartbeat wake)`);
|
|
79
|
+
|
|
80
|
+
const deadline = Date.now() + timeoutMs;
|
|
81
|
+
// NOTE: Date.now() is fine — live runtime path, not a resumable workflow.
|
|
82
|
+
for (;;) {
|
|
83
|
+
const p = pollTask(taskId);
|
|
84
|
+
if (p.status === "done") return { status: "done", output: p.result || "", ref: taskId };
|
|
85
|
+
if (p.status === "cancelled") return { status: "cancelled", output: p.result || "", ref: taskId };
|
|
86
|
+
if (Date.now() > deadline) return { status: "timeout", output: "", ref: taskId };
|
|
87
|
+
spawnSync("sleep", [String(Math.ceil(pollEveryMs / 1000))]);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Portable backend — the harness-agnostic reference path (a JS port of the
|
|
2
|
+
// reference run-loop.py). Runs each role as ONE headless invocation of the
|
|
3
|
+
// harness's own model command (`claude -p`, `codex exec`, …) and threads the
|
|
4
|
+
// structured output forward. Nothing host-specific beyond `headlessCmd`; the
|
|
5
|
+
// loop file is identical across harnesses. Used for every non-5dive harness.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
function runOnce(cmd, prompt, { timeout = 180000 } = {}) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const [bin, ...args] = cmd;
|
|
11
|
+
const child = spawn(bin, [...args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
|
|
12
|
+
let out = "";
|
|
13
|
+
let err = "";
|
|
14
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
|
|
15
|
+
child.stdout.on("data", (d) => (out += d));
|
|
16
|
+
child.stderr.on("data", (d) => (err += d));
|
|
17
|
+
child.on("error", (e) => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
resolve({ ok: false, output: "", error: `${bin} not runnable: ${e.message}` });
|
|
20
|
+
});
|
|
21
|
+
child.on("close", (code) => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
if (code === 0) resolve({ ok: true, output: out.trim() });
|
|
24
|
+
else resolve({ ok: false, output: out.trim(), error: err.trim() || `exit ${code}` });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// backend factory. headlessCmd e.g. ["claude","-p"].
|
|
30
|
+
export function portableBackend(headlessCmd, opts = {}) {
|
|
31
|
+
if (!Array.isArray(headlessCmd) || !headlessCmd.length)
|
|
32
|
+
throw new Error("portable backend needs a headlessCmd, e.g. [\"claude\",\"-p\"]");
|
|
33
|
+
return {
|
|
34
|
+
label: `portable (${headlessCmd.join(" ")})`,
|
|
35
|
+
async runRole({ prompt }) {
|
|
36
|
+
const r = await runOnce(headlessCmd, prompt, opts);
|
|
37
|
+
return {
|
|
38
|
+
status: r.ok ? "done" : "error",
|
|
39
|
+
output: r.output,
|
|
40
|
+
ref: headlessCmd[0],
|
|
41
|
+
error: r.error,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/chain.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The harness-agnostic run engine. A multi-agent loop is ONE run executed as an
|
|
2
|
+
// ordered chain of role-prompts; each role's structured output is threaded into
|
|
3
|
+
// the next via {{previous_output}}. HOW a role is run (a local headless process,
|
|
4
|
+
// a linked 5dive task, …) is the BACKEND's business — the engine only sequences
|
|
5
|
+
// and threads. Backend contract:
|
|
6
|
+
// async runRole({ role, persona, prompt, index, total, onLog }) -> { output, ref, status }
|
|
7
|
+
// status === "done" continues; anything else stops the chain.
|
|
8
|
+
|
|
9
|
+
// Thread the prior role's structured output into a role's prompt: substitute the
|
|
10
|
+
// {{previous_output}} token if present, else prepend a context block. Role 1
|
|
11
|
+
// (no prior output) just strips a dangling token.
|
|
12
|
+
export function resolvePrompt(prompt, previous) {
|
|
13
|
+
const TOKEN = /\{\{\s*previous_output\s*\}\}/g;
|
|
14
|
+
if (!previous) return prompt.replace(TOKEN, "").trimEnd();
|
|
15
|
+
if (TOKEN.test(prompt)) return prompt.replace(TOKEN, previous);
|
|
16
|
+
return `--- context from the previous role ---\n${previous}\n--- end context ---\n\n${prompt}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runChain(loopName, roles, backend, opts = {}) {
|
|
20
|
+
const onStep = opts.onStep || (() => {});
|
|
21
|
+
const steps = [];
|
|
22
|
+
let previous = opts.initialContext || null;
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < roles.length; i++) {
|
|
25
|
+
const role = roles[i];
|
|
26
|
+
const prompt = resolvePrompt(role.prompt, previous);
|
|
27
|
+
onStep({ phase: "start", role: role.role, index: i + 1, total: roles.length, handoffChars: previous?.length || 0 });
|
|
28
|
+
|
|
29
|
+
let res;
|
|
30
|
+
try {
|
|
31
|
+
res = await backend.runRole({
|
|
32
|
+
role: role.role,
|
|
33
|
+
persona: role.persona,
|
|
34
|
+
prompt,
|
|
35
|
+
index: i + 1,
|
|
36
|
+
total: roles.length,
|
|
37
|
+
onLog: (m) => onStep({ phase: "log", role: role.role, message: m }),
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
res = { status: "error", output: "", ref: null, error: e.message };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const status = res.status || "done";
|
|
44
|
+
steps.push({ role: role.role, persona: role.persona, ref: res.ref || null, status, output: res.output || "" });
|
|
45
|
+
onStep({ phase: "done", role: role.role, index: i + 1, status, ref: res.ref, error: res.error });
|
|
46
|
+
|
|
47
|
+
if (status !== "done") {
|
|
48
|
+
return { ok: false, loop: loopName, failedAt: role.role, steps, receipt: receiptBody(loopName, steps, false) };
|
|
49
|
+
}
|
|
50
|
+
previous = res.output || "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { ok: true, loop: loopName, steps, receipt: receiptBody(loopName, steps, true) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The unsigned receipt body. The signer (openagent did:key) wraps this.
|
|
57
|
+
export function receiptBody(loopName, steps, ok) {
|
|
58
|
+
return {
|
|
59
|
+
loop: loopName,
|
|
60
|
+
ok,
|
|
61
|
+
ranAt: null, // stamped by the caller (engine stays time-pure for testability)
|
|
62
|
+
roles: steps.map((s) => ({
|
|
63
|
+
role: s.role,
|
|
64
|
+
persona: s.persona,
|
|
65
|
+
ref: s.ref,
|
|
66
|
+
status: s.status,
|
|
67
|
+
outputChars: (s.output || "").length,
|
|
68
|
+
})),
|
|
69
|
+
finalRole: steps.length ? steps[steps.length - 1].role : null,
|
|
70
|
+
outputs: Object.fromEntries(steps.map((s) => [s.role, s.output || null])),
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -15,4 +15,4 @@ export const SKILLS_REGISTRY_URL = "https://skills.sh/index.json";
|
|
|
15
15
|
export const SPEC_VERSION = "0.1";
|
|
16
16
|
|
|
17
17
|
// User-Agent so server logs can attribute pings to the CLI (still anonymous).
|
|
18
|
-
export const UA = `agenticloops-cli/0.1.
|
|
18
|
+
export const UA = `agenticloops-cli/0.1.2 (+${SITE})`;
|
package/src/harness.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// loop's skills, honor its trigger, and run the prompt. Crucially, a loop needs
|
|
3
3
|
// SCHEDULING — a harness that can only run interactively (an IDE) can run the
|
|
4
4
|
// agent but cannot honor a recurring trigger; we warn when targeting one.
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
5
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { execSync } from "node:child_process";
|
|
8
9
|
|
|
9
10
|
function has(bin) {
|
|
@@ -15,36 +16,50 @@ function has(bin) {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
// Each harness: id, label, canSchedule,
|
|
19
|
+
// Each harness: id, label, canSchedule, headlessCmd (how it runs ONE prompt
|
|
20
|
+
// non-interactively — the portable multi-agent backend shells this), and a
|
|
21
|
+
// detect() that inspects cwd/env.
|
|
19
22
|
export const HARNESSES = [
|
|
20
23
|
{
|
|
21
24
|
id: "5dive",
|
|
22
25
|
label: "5dive runtime",
|
|
23
26
|
canSchedule: true,
|
|
27
|
+
headlessCmd: ["claude", "-p"],
|
|
24
28
|
detect: () => has("5dive") || existsSync("/var/lib/5dive"),
|
|
25
29
|
},
|
|
26
30
|
{
|
|
27
31
|
id: "github-actions",
|
|
28
32
|
label: "GitHub Actions",
|
|
29
33
|
canSchedule: true,
|
|
34
|
+
headlessCmd: ["claude", "-p"],
|
|
30
35
|
detect: (cwd) => existsSync(join(cwd, ".github", "workflows")) || !!process.env.GITHUB_ACTIONS,
|
|
31
36
|
},
|
|
32
37
|
{
|
|
33
38
|
id: "claude-code",
|
|
34
39
|
label: "Claude Code",
|
|
35
40
|
canSchedule: false,
|
|
41
|
+
headlessCmd: ["claude", "-p"],
|
|
36
42
|
detect: (cwd) => existsSync(join(cwd, ".claude")) || has("claude"),
|
|
37
43
|
},
|
|
44
|
+
{
|
|
45
|
+
id: "codex",
|
|
46
|
+
label: "Codex CLI",
|
|
47
|
+
canSchedule: false,
|
|
48
|
+
headlessCmd: ["codex", "exec"],
|
|
49
|
+
detect: (cwd) => existsSync(join(cwd, ".codex")) || has("codex"),
|
|
50
|
+
},
|
|
38
51
|
{
|
|
39
52
|
id: "cursor",
|
|
40
53
|
label: "Cursor",
|
|
41
54
|
canSchedule: false,
|
|
55
|
+
headlessCmd: ["claude", "-p"], // no native headless; use claude if present
|
|
42
56
|
detect: (cwd) => existsSync(join(cwd, ".cursor")),
|
|
43
57
|
},
|
|
44
58
|
{
|
|
45
59
|
id: "cron",
|
|
46
60
|
label: "system cron",
|
|
47
61
|
canSchedule: true,
|
|
62
|
+
headlessCmd: ["claude", "-p"],
|
|
48
63
|
detect: () => has("crontab"),
|
|
49
64
|
},
|
|
50
65
|
];
|
|
@@ -53,6 +68,32 @@ export function getHarness(id) {
|
|
|
53
68
|
return HARNESSES.find((h) => h.id === id);
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
// Where each harness keeps installed skills (a dir of <skill>/SKILL.md). Used
|
|
72
|
+
// to mark a skill "host-satisfied" only when it's VERIFIABLY present, so the
|
|
73
|
+
// skip is honest — never an asserted-but-unchecked built-in.
|
|
74
|
+
const SKILL_DIRS = {
|
|
75
|
+
"claude-code": [join(homedir(), ".claude", "skills")],
|
|
76
|
+
cursor: [join(homedir(), ".cursor", "skills")],
|
|
77
|
+
"5dive": [join(homedir(), ".claude", "skills")],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function hostSkillsFor(harnessId) {
|
|
81
|
+
const dirs = SKILL_DIRS[harnessId] || [];
|
|
82
|
+
const found = new Set();
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
if (!existsSync(dir)) continue;
|
|
85
|
+
try {
|
|
86
|
+
for (const name of readdirSync(dir)) {
|
|
87
|
+
const p = join(dir, name);
|
|
88
|
+
if (statSync(p).isDirectory() && existsSync(join(p, "SKILL.md"))) found.add(name);
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* unreadable dir — skip */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...found];
|
|
95
|
+
}
|
|
96
|
+
|
|
56
97
|
// Auto-detect: prefer a schedulable harness when several are present, since a
|
|
57
98
|
// loop's whole point is the recurring trigger. Returns { harness, all }.
|
|
58
99
|
export function detectHarness(cwd = process.cwd()) {
|
package/src/install.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The install flow (SPEC §3): fetch+validate LOOP.md -> install skills ->
|
|
2
2
|
// pre-flight `requires` (prompt-or-refuse, never auto-install) -> register the
|
|
3
3
|
// scheduled job -> (on success) anonymous telemetry ping.
|
|
4
|
-
import { fetchLoopMd, parseLoopMd, validateManifest, triggerOf } from "./loop.mjs";
|
|
5
|
-
import { detectHarness, getHarness } from "./harness.mjs";
|
|
4
|
+
import { fetchLoopMd, parseLoopMd, validateManifest, triggerOf, isMultiAgent, rolesOf, allSkills } from "./loop.mjs";
|
|
5
|
+
import { detectHarness, getHarness, hostSkillsFor } from "./harness.mjs";
|
|
6
6
|
import { planSkills, installSkill } from "./skills.mjs";
|
|
7
7
|
import { preflight } from "./preflight.mjs";
|
|
8
8
|
import { parseSchedule, registerTrigger, saveRecord } from "./schedule.mjs";
|
|
@@ -39,8 +39,10 @@ export async function install(ref, opts = {}) {
|
|
|
39
39
|
}
|
|
40
40
|
const slug = manifest.name;
|
|
41
41
|
const trig = triggerOf(manifest);
|
|
42
|
+
const multi = isMultiAgent(manifest);
|
|
42
43
|
ok(`${c.bold(manifest.name)} — ${manifest.description?.split("\n")[0] ?? ""}`);
|
|
43
44
|
info(`trigger: ${trig.kind === "event" ? `on ${trig.value}` : trig.value}`);
|
|
45
|
+
if (multi) info(`multi-agent: ${rolesOf(manifest).map((r) => r.role).join(" → ")} ${c.dim("(sequential chain)")}`);
|
|
44
46
|
|
|
45
47
|
// 2. Pick the harness (auto-detect unless --harness given) + warn if it can't schedule.
|
|
46
48
|
step("Target harness");
|
|
@@ -71,7 +73,10 @@ export async function install(ref, opts = {}) {
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
// 3. Install skills (§3.1) — host-satisfied skipped, paths fetched, bare resolved.
|
|
74
|
-
|
|
76
|
+
// Host-satisfied = verifiably present in the harness's skills dir (not asserted).
|
|
77
|
+
const hostSkills = [...new Set([...(opts.hostSkills || []), ...hostSkillsFor(harness.id)])];
|
|
78
|
+
// Multi-agent: install the union of shared + per-role skills.
|
|
79
|
+
const skillPlan = await planSkills(multi ? allSkills(manifest) : manifest.skills || [], hostSkills);
|
|
75
80
|
if (skillPlan.length) {
|
|
76
81
|
step("Skills");
|
|
77
82
|
for (const s of skillPlan) {
|
|
@@ -138,13 +143,18 @@ export async function install(ref, opts = {}) {
|
|
|
138
143
|
trigger: trig,
|
|
139
144
|
cron: sched.cron || null,
|
|
140
145
|
description: manifest.description,
|
|
146
|
+
multiAgent: multi,
|
|
147
|
+
roles: multi ? rolesOf(manifest).map((r) => r.role) : null,
|
|
141
148
|
installedAt: new Date().toISOString(),
|
|
142
149
|
};
|
|
143
150
|
if (!dryRun) saveRecord(slug, record, fetched.raw);
|
|
151
|
+
// A multi-agent loop's scheduled body runs the whole chain via the CLI; a
|
|
152
|
+
// single-prompt loop's body is the prompt itself.
|
|
153
|
+
const body = multi ? `npx agenticloops run ${loopRef} --harness=${harness.id}` : prompt;
|
|
144
154
|
const reg = registerTrigger(harness.id, {
|
|
145
155
|
slug,
|
|
146
156
|
name: manifest.name,
|
|
147
|
-
prompt,
|
|
157
|
+
prompt: body,
|
|
148
158
|
cron: sched.cron || manifest.schedule || "",
|
|
149
159
|
dryRun,
|
|
150
160
|
});
|
|
@@ -162,5 +172,12 @@ export async function install(ref, opts = {}) {
|
|
|
162
172
|
step(`${sym.ok} Installed ${c.bold(slug)} on ${harness.label}${dryRun ? c.dim(" (dry-run)") : ""}`);
|
|
163
173
|
if (reg.scheduled === false && trig.needsScheduler)
|
|
164
174
|
info(`it won't fire until you wire a scheduler — see ${c.cyan("agenticloops list")}`);
|
|
175
|
+
|
|
176
|
+
// --run executes the chain once now (the install-and-it-runs demo moment).
|
|
177
|
+
if (opts.run && !dryRun) {
|
|
178
|
+
const { runLoop } = await import("./runcmd.mjs");
|
|
179
|
+
const res = await runLoop(loopRef, { harness: harness.id });
|
|
180
|
+
return { ...record, firstRun: res.receipt, signed: res.signed };
|
|
181
|
+
}
|
|
165
182
|
return record;
|
|
166
183
|
}
|
package/src/loop.mjs
CHANGED
|
@@ -99,9 +99,52 @@ export function validateManifest(manifest) {
|
|
|
99
99
|
if (m.skills && !Array.isArray(m.skills)) errs.push("`skills` must be a list");
|
|
100
100
|
if (m.requires && typeof m.requires !== "object") errs.push("`requires` must be a mapping");
|
|
101
101
|
|
|
102
|
+
// Multi-agent (§ agents:) — an ordered chain of role-prompts. Additive: a loop
|
|
103
|
+
// with no `agents:` is the single-prompt body, unchanged.
|
|
104
|
+
if (m.agents !== undefined) {
|
|
105
|
+
if (!Array.isArray(m.agents) || m.agents.length === 0) {
|
|
106
|
+
errs.push("`agents` must be a non-empty list of roles");
|
|
107
|
+
} else {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
m.agents.forEach((a, i) => {
|
|
110
|
+
if (!a || typeof a !== "object") return errs.push(`agents[${i}] must be a mapping`);
|
|
111
|
+
if (!a.role || !/^[a-z0-9][a-z0-9-]*$/.test(a.role))
|
|
112
|
+
errs.push(`agents[${i}].role must be a kebab-case id`);
|
|
113
|
+
else if (seen.has(a.role)) errs.push(`duplicate role "${a.role}"`);
|
|
114
|
+
else seen.add(a.role);
|
|
115
|
+
if (!a.prompt || typeof a.prompt !== "string")
|
|
116
|
+
errs.push(`agents[${i}] (${a.role || i}) is missing a prompt`);
|
|
117
|
+
if (a.skills && !Array.isArray(a.skills))
|
|
118
|
+
errs.push(`agents[${i}].skills must be a list`);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
102
123
|
return errs;
|
|
103
124
|
}
|
|
104
125
|
|
|
126
|
+
// A loop is multi-agent when it carries a non-empty ordered `agents:` chain.
|
|
127
|
+
export function isMultiAgent(manifest) {
|
|
128
|
+
return Array.isArray(manifest?.agents) && manifest.agents.length > 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Normalised roles for the run engine. Role 1 gets no prior output.
|
|
132
|
+
export function rolesOf(manifest) {
|
|
133
|
+
return (manifest.agents || []).map((a) => ({
|
|
134
|
+
role: a.role,
|
|
135
|
+
persona: a.persona || null,
|
|
136
|
+
skills: Array.isArray(a.skills) ? a.skills : [],
|
|
137
|
+
prompt: a.prompt,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Every skill the loop touches: top-level (shared) ∪ per-role (additive).
|
|
142
|
+
export function allSkills(manifest) {
|
|
143
|
+
const top = Array.isArray(manifest.skills) ? manifest.skills : [];
|
|
144
|
+
const perRole = (manifest.agents || []).flatMap((a) => (Array.isArray(a.skills) ? a.skills : []));
|
|
145
|
+
return [...new Set([...top, ...perRole])];
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
// A trigger that a harness must be able to honor. `schedule` accepts human
|
|
106
149
|
// grammar or raw cron; we don't expand it here — adapters do — but we surface a
|
|
107
150
|
// label and whether scheduling (vs event) is needed.
|
package/src/receipt.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Run receipts — REUSE the canonical openagent receipt scheme so the bytes match
|
|
2
|
+
// card provenance, A2A handshakes, and zerohuman edges (one verifier ranks the
|
|
3
|
+
// whole directory: "proof, not popularity"). We import lib/receipts.js directly
|
|
4
|
+
// from the published @5dive/openagent and reimplement only the tiny keystore
|
|
5
|
+
// loader on lib/provenance.js (keystore.js isn't published) — SAME
|
|
6
|
+
// ~/.openagent/agent.key path + SAME keygen, so the did:key is byte-identical.
|
|
7
|
+
//
|
|
8
|
+
// v0.1 = SINGLE-SIGNER run receipt: the loop owner's did signs one body over the
|
|
9
|
+
// whole run (hashed outputs, never the raw text). An honest attestation that the
|
|
10
|
+
// run happened with these outputs. v0.2 = per-handoff co-signed EDGES on the
|
|
11
|
+
// 5dive backend where each role is a distinct keyed agent (receipts.cosign()).
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
// Both come straight from the published @5dive/openagent package (>=0.35.0 ships
|
|
19
|
+
// lib/receipts.js), so signatures are byte-identical to card provenance /
|
|
20
|
+
// zerohuman edges — no vendored copy to drift from the canonical scheme.
|
|
21
|
+
const receipts = require("@5dive/openagent/lib/receipts.js");
|
|
22
|
+
const provenance = require("@5dive/openagent/lib/provenance.js");
|
|
23
|
+
|
|
24
|
+
function agentHome() {
|
|
25
|
+
const env = process.env.OPENAGENT_HOME;
|
|
26
|
+
if (env && env.trim()) {
|
|
27
|
+
const e = env.trim();
|
|
28
|
+
return e.startsWith("~") ? path.join(os.homedir(), e.slice(1)) : path.resolve(e);
|
|
29
|
+
}
|
|
30
|
+
return path.join(os.homedir(), ".openagent");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Load the agent's keystore key (~/.openagent/agent.key), minting one on first
|
|
34
|
+
// use — identical layout + keygen to openagent/lib/keystore.js so the identity
|
|
35
|
+
// is the same one the agent's card/handshakes use.
|
|
36
|
+
export function loadOrCreateKey() {
|
|
37
|
+
const home = agentHome();
|
|
38
|
+
const keyPath = path.join(home, "agent.key");
|
|
39
|
+
let privateKey;
|
|
40
|
+
try {
|
|
41
|
+
privateKey = fs.readFileSync(keyPath, "utf8").trim();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e.code !== "ENOENT") throw e;
|
|
44
|
+
const kp = provenance.generateKeypair();
|
|
45
|
+
fs.mkdirSync(home, { recursive: true });
|
|
46
|
+
fs.writeFileSync(keyPath, kp.privateKey + "\n", { mode: 0o600 });
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(path.join(home, "agent.pub"), kp.publicKey + "\n", { mode: 0o644 });
|
|
49
|
+
} catch {
|
|
50
|
+
/* pub is a convenience */
|
|
51
|
+
}
|
|
52
|
+
privateKey = kp.privateKey;
|
|
53
|
+
}
|
|
54
|
+
const publicKey = provenance.publicPemFromPrivate(privateKey);
|
|
55
|
+
return { privateKey, publicKey, did: provenance.didKeyFromPublicKey(publicKey) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Sign a single-signer run receipt over the chain. `steps` = [{role, persona,
|
|
59
|
+
// output}]. Outputs are sha256-hashed (size/privacy) — never embedded.
|
|
60
|
+
export function signRunReceipt({ loop, spec = "0.1", steps, at }) {
|
|
61
|
+
const key = loadOrCreateKey();
|
|
62
|
+
const body = {
|
|
63
|
+
v: 1,
|
|
64
|
+
loop,
|
|
65
|
+
spec,
|
|
66
|
+
roles: steps.map((s) => ({
|
|
67
|
+
role: s.role,
|
|
68
|
+
persona: s.persona || null,
|
|
69
|
+
output_hash: receipts.hash(s.output || ""),
|
|
70
|
+
})),
|
|
71
|
+
final: steps.length ? receipts.hash(steps[steps.length - 1].output || "") : null,
|
|
72
|
+
at,
|
|
73
|
+
};
|
|
74
|
+
return { receipt: body, sigs: [receipts.sign(body, key.privateKey)], signer: key.did };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Verify a single-signer run receipt: the signature verifies over the body and
|
|
78
|
+
// its `by` did matches its embedded key. (requireBoth=false — this is an
|
|
79
|
+
// attestation, not a two-party edge.)
|
|
80
|
+
export function verifyRunReceipt(signed) {
|
|
81
|
+
return receipts.verify(signed, { requireBoth: false });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { receipts };
|
package/src/runcmd.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// `agenticloops run <ref>` — execute a loop's chain ONCE, now. This is what a
|
|
2
|
+
// schedule fires (a recurring 5dive task / GH Actions job calls it), and it's
|
|
3
|
+
// how you test a loop locally. Routes to a backend by harness: 5dive uses the
|
|
4
|
+
// native linked-task chain; everything else uses the portable headless runner.
|
|
5
|
+
import { fetchLoopMd, parseLoopMd, validateManifest, isMultiAgent, rolesOf } from "./loop.mjs";
|
|
6
|
+
import { detectHarness, getHarness } from "./harness.mjs";
|
|
7
|
+
import { portableBackend } from "./backends/portable.mjs";
|
|
8
|
+
import { fivediveBackend } from "./backends/fivedive.mjs";
|
|
9
|
+
import { runChain } from "./chain.mjs";
|
|
10
|
+
import { signRunReceipt } from "./receipt.mjs";
|
|
11
|
+
import { c, info, ok, warn, fail, step, CliError } from "./util.mjs";
|
|
12
|
+
|
|
13
|
+
function pickBackend(harness, opts) {
|
|
14
|
+
// harness=5dive -> host-optimized linked-task chain (distinct agents, task
|
|
15
|
+
// receipts). Everything else -> portable headless runner. --backend overrides.
|
|
16
|
+
const choice = opts.backend || (harness.id === "5dive" ? "5dive" : "portable");
|
|
17
|
+
if (choice === "5dive") return fivediveBackend(opts);
|
|
18
|
+
if (!harness.headlessCmd)
|
|
19
|
+
throw new CliError(`harness ${harness.id} has no headless run command for the portable backend`, 2);
|
|
20
|
+
return portableBackend(harness.headlessCmd, opts);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runLoop(ref, opts = {}) {
|
|
24
|
+
const fetched = await fetchLoopMd(ref);
|
|
25
|
+
const { manifest, prompt } = parseLoopMd(fetched.raw);
|
|
26
|
+
const errs = validateManifest(manifest);
|
|
27
|
+
if (errs.length) {
|
|
28
|
+
errs.forEach((e) => fail(e));
|
|
29
|
+
throw new CliError("LOOP.md failed validation", 3);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Single-prompt loops run as a one-role chain so one engine handles both.
|
|
33
|
+
const roles = isMultiAgent(manifest)
|
|
34
|
+
? rolesOf(manifest)
|
|
35
|
+
: [{ role: "main", persona: manifest.persona || null, skills: manifest.skills || [], prompt }];
|
|
36
|
+
|
|
37
|
+
const harness = opts.harness
|
|
38
|
+
? getHarness(opts.harness) || (() => { throw new CliError(`unknown harness "${opts.harness}"`, 2); })()
|
|
39
|
+
: detectHarness().harness;
|
|
40
|
+
if (!harness) throw new CliError("no harness detected — pass --harness=<id>", 4);
|
|
41
|
+
|
|
42
|
+
const backend = pickBackend(harness, { ...opts, loop: manifest.name });
|
|
43
|
+
step(`Running ${c.bold(manifest.name)} — ${roles.length} role${roles.length > 1 ? "s" : ""} · ${backend.label}`);
|
|
44
|
+
|
|
45
|
+
const result = await runChain(manifest.name, roles, backend, {
|
|
46
|
+
onStep: (s) => {
|
|
47
|
+
if (s.phase === "start")
|
|
48
|
+
info(`role ${s.index}/${s.total}: ${c.bold(s.role)}${s.handoffChars ? c.dim(` ← ${s.handoffChars} chars`) : ""}`);
|
|
49
|
+
else if (s.phase === "done")
|
|
50
|
+
s.status === "done"
|
|
51
|
+
? ok(`${s.role} ${c.dim(s.ref ? "(" + s.ref + ")" : "")}`)
|
|
52
|
+
: fail(`${s.role} → ${s.status}${s.error ? ": " + s.error : ""}`);
|
|
53
|
+
else if (s.phase === "log") info(c.dim(` ${s.message}`));
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const at = new Date().toISOString();
|
|
58
|
+
result.receipt.ranAt = at;
|
|
59
|
+
|
|
60
|
+
// Sign a single-signer run receipt with the owner's openagent did:key — same
|
|
61
|
+
// scheme as card provenance / zerohuman edges, so one verifier ranks them all.
|
|
62
|
+
if (opts.sign !== false) {
|
|
63
|
+
try {
|
|
64
|
+
result.signed = signRunReceipt({ loop: manifest.name, steps: result.steps, at });
|
|
65
|
+
info(c.dim(`receipt signed by ${result.signed.signer}`));
|
|
66
|
+
} catch (e) {
|
|
67
|
+
warn(`receipt signing skipped: ${e.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (result.ok) step(`${c.green("✓")} chain complete — final output by ${c.bold(result.receipt.finalRole)}`);
|
|
72
|
+
else warn(`chain stopped at ${result.failedAt}`);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
package/src/schedule.mjs
CHANGED
|
@@ -70,8 +70,9 @@ export function removeRecord(slug) {
|
|
|
70
70
|
// --- harness adapters: register the recurring trigger -----------------------
|
|
71
71
|
|
|
72
72
|
function registerOn5dive({ name, prompt, cron, dryRun }) {
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// 5dive flags are =form (`--body=x`, not `--body x`) — caught in the spike.
|
|
74
|
+
const args = ["task", "add", name, `--body=${prompt}`, `--recurring=${cron}`];
|
|
75
|
+
if (dryRun) return { ok: true, detail: `5dive task add ${name} --recurring=${cron}`, skipped: "dry-run" };
|
|
75
76
|
const res = spawnSync("5dive", args, { stdio: "inherit" });
|
|
76
77
|
return { ok: res.status === 0, detail: `5dive ${args.join(" ")}`, status: res.status };
|
|
77
78
|
}
|