Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48bf701281 | |||
| 3b498069b6 | |||
| 984d93a6f5 | |||
| 2274de29c3 | |||
| 911cbf2a8a | |||
| 09a5da2df2 | |||
| e4c228d36e | |||
| f8de0e913b | |||
| cb97507e9a | |||
| 4b442bb251 | |||
| ac53128ff7 | |||
| 607366c469 | |||
| 577fb27470 | |||
| 5475dd3f5c | |||
| 09b7ddf6d0 | |||
| c4e94bbe56 | |||
| dbefe793f2 | |||
| 6483bc4861 | |||
| fecb02b115 | |||
| 87938c1886 | |||
| 95a130136b | |||
| aba5642908 |
@@ -143,6 +143,44 @@ defaultModel: default
|
||||
const masked = maskApiKeys(config);
|
||||
expect(masked).toEqual(config);
|
||||
});
|
||||
|
||||
test("does not mask non-provider apiKey fields", () => {
|
||||
const config = {
|
||||
apiKey: "root-level-key",
|
||||
providers: {
|
||||
dashscope: { apiKey: "sk-secret" },
|
||||
},
|
||||
models: {
|
||||
default: { provider: "dashscope" },
|
||||
},
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
// Root-level apiKey should NOT be masked
|
||||
expect(masked.apiKey).toBe("root-level-key");
|
||||
// Provider apiKey SHOULD be masked
|
||||
const providers = masked.providers as Record<string, Record<string, unknown>>;
|
||||
expect(providers.dashscope.apiKey).toBe("***MASKED***");
|
||||
});
|
||||
|
||||
test("handles empty provider object", () => {
|
||||
const config = {
|
||||
providers: { dashscope: {} },
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
expect(masked).toEqual({ providers: { dashscope: {} } });
|
||||
});
|
||||
|
||||
test("handles provider with null apiKey", () => {
|
||||
const config = {
|
||||
providers: {
|
||||
dashscope: { apiKey: null, baseUrl: "https://example.com" },
|
||||
},
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
const providers = masked.providers as Record<string, Record<string, unknown>>;
|
||||
expect(providers.dashscope.apiKey).toBe("***MASKED***");
|
||||
expect(providers.dashscope.baseUrl).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -618,5 +656,82 @@ defaultModel: default
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agentOverrides — accepts valid 3-segment path", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "agentOverrides.solve-issue.planner", "claude-code");
|
||||
const value = await cmdConfigGet(tempDir, "agentOverrides.solve-issue.planner");
|
||||
expect(value).toBe("claude-code");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agentOverrides — rejects incomplete path (2 segments)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "agentOverrides.solve-issue", "hermes")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("modelOverrides — accepts valid 2-segment path", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "modelOverrides.extract", "gpt4");
|
||||
const value = await cmdConfigGet(tempDir, "modelOverrides.extract");
|
||||
expect(value).toBe("gpt4");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("modelOverrides — rejects incomplete path (1 segment only)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "modelOverrides", "gpt4")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown top-level key (regression)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "randomKey", "value")).rejects.toThrow(
|
||||
/Unknown config key/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("no legacy apiKeyEnv references", () => {
|
||||
test("config.ts has no references to apiKeyEnv", () => {
|
||||
const configSource = readFileSync(join(__dirname, "..", "commands", "config.ts"), "utf8");
|
||||
expect(configSource).not.toContain("apiKeyEnv");
|
||||
});
|
||||
|
||||
test("config.test.ts has no references to apiKeyEnv (except this test)", () => {
|
||||
const testSource = readFileSync(__filename, "utf8");
|
||||
// Remove this test block's own mentions before checking
|
||||
const withoutThisTest = testSource.replace(
|
||||
/describe\("no legacy apiKeyEnv references"[\s\S]*$/,
|
||||
"",
|
||||
);
|
||||
expect(withoutThisTest).not.toContain("apiKeyEnv");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,17 @@ import { evaluate } from "../moderator/evaluate.js";
|
||||
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task.", location: null },
|
||||
},
|
||||
planner: {
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null },
|
||||
},
|
||||
developer: {
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null },
|
||||
},
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done." },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||
approved: { role: "$END", prompt: "Done.", location: null },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,7 +24,11 @@ describe("evaluate", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
value: {
|
||||
role: "planner",
|
||||
prompt: "Start planning from the issue in the task.",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +39,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||
value: { role: "developer", prompt: "Fix: missing tests", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +47,7 @@ describe("evaluate", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
value: { role: "$END", prompt: "Done.", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +74,11 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
value: {
|
||||
role: "developer",
|
||||
prompt: "Implement the plan: Add auth middleware",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,14 +89,14 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types', location: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
@@ -97,7 +105,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +115,11 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
value: {
|
||||
role: "developer",
|
||||
prompt: "Implement the plan: Add auth middleware",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +129,7 @@ describe("evaluate", () => {
|
||||
_: {
|
||||
role: "developer",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -126,7 +139,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||
value: { role: "developer", prompt: "Address: refactor the handler", location: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,15 @@ import { describe, expect, test } from "vitest";
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
import {
|
||||
cmdSkillActor,
|
||||
cmdSkillAdapter,
|
||||
cmdSkillArchitecture,
|
||||
cmdSkillAuthor,
|
||||
cmdSkillCli,
|
||||
cmdSkillDeveloper,
|
||||
cmdSkillList,
|
||||
cmdSkillModerator,
|
||||
cmdSkillUser,
|
||||
cmdSkillYaml,
|
||||
} from "../commands/skill.js";
|
||||
|
||||
@@ -21,8 +26,12 @@ describe("skill commands", () => {
|
||||
expect(result).toContain("architecture");
|
||||
expect(result).toContain("yaml");
|
||||
expect(result).toContain("moderator");
|
||||
expect(result).toContain("actor");
|
||||
expect(result).toContain("user");
|
||||
expect(result).toContain("author");
|
||||
expect(result).toContain("developer");
|
||||
expect(result).toContain("adapter");
|
||||
for (const name of result) {
|
||||
expect(typeof name).toBe("string");
|
||||
expect(name).toMatch(/^\S+$/);
|
||||
}
|
||||
});
|
||||
@@ -62,6 +71,54 @@ describe("skill commands", () => {
|
||||
expect(result).toContain("uwf");
|
||||
});
|
||||
|
||||
test("skill actor returns non-empty markdown string", () => {
|
||||
const result = cmdSkillActor();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("frontmatter");
|
||||
expect(result).toContain("CAS");
|
||||
expect(result).toContain("status");
|
||||
expect(result.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
test("skill user returns non-empty markdown string", () => {
|
||||
const result = cmdSkillUser();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("uwf");
|
||||
expect(result).toContain("thread");
|
||||
expect(result).toContain("workflow");
|
||||
expect(result).toContain("Quick Start");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("skill author returns non-empty markdown string", () => {
|
||||
const result = cmdSkillAuthor();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("frontmatter");
|
||||
expect(result).toContain("graph");
|
||||
expect(result).toContain("$START");
|
||||
expect(result).toContain("$END");
|
||||
expect(result).toContain("$status");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("skill developer returns non-empty markdown string", () => {
|
||||
const result = cmdSkillDeveloper();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("Monorepo");
|
||||
expect(result).toContain("CAS");
|
||||
expect(result).toContain("Biome");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("skill adapter returns non-empty markdown string", () => {
|
||||
const result = cmdSkillAdapter();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("createAgent");
|
||||
expect(result).toContain("AgentContext");
|
||||
expect(result).toContain("frontmatter");
|
||||
expect(result.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("skill help subcommand is suppressed", () => {
|
||||
const output = execFileSync("bun", ["src/cli.ts", "skill", "--help"], {
|
||||
cwd: join(__dirname, "..", ".."),
|
||||
@@ -73,6 +130,11 @@ describe("skill commands", () => {
|
||||
expect(output).toContain("architecture");
|
||||
expect(output).toContain("yaml");
|
||||
expect(output).toContain("moderator");
|
||||
expect(output).toContain("actor");
|
||||
expect(output).toContain("user");
|
||||
expect(output).toContain("author");
|
||||
expect(output).toContain("developer");
|
||||
expect(output).toContain("adapter");
|
||||
expect(output).toContain("list");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ describe("protocol types", () => {
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
cwd: "/test/path",
|
||||
};
|
||||
expect(record.startedAtMs).toBe(1000);
|
||||
expect(record.completedAtMs).toBe(2000);
|
||||
@@ -239,8 +240,8 @@ describe("thread read timing", () => {
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go" } },
|
||||
worker: { _: { role: "$END", prompt: "" } },
|
||||
$START: { _: { role: "worker", prompt: "go", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -305,8 +306,8 @@ describe("thread read timing", () => {
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go" } },
|
||||
worker: { _: { role: "$END", prompt: "" } },
|
||||
$START: { _: { role: "worker", prompt: "go", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
import { createUwfStore } from "../store.js";
|
||||
|
||||
describe("Thread and edge location integration", () => {
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
|
||||
async function setupTestEnv() {
|
||||
tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`);
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("thread start captures cwd in StartNode", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-location
|
||||
description: Test workflow for location feature
|
||||
roles:
|
||||
planner:
|
||||
description: Plans the work
|
||||
goal: Plan implementation
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan the work"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-location.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
const testCwd = "/test/project/path";
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
|
||||
|
||||
expect(result.thread).toBeDefined();
|
||||
expect(result.workflow).toBeDefined();
|
||||
|
||||
// Verify StartNode has the cwd field
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
const headHash = index[result.thread as ThreadId];
|
||||
expect(headHash).toBeDefined();
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
expect(startNode).not.toBe(null);
|
||||
expect(startNode?.type).toBe(uwf.schemas.startNode);
|
||||
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
expect(startPayload.cwd).toBe(testCwd);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start validates cwd is absolute path", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-location
|
||||
description: Test workflow
|
||||
roles:
|
||||
planner:
|
||||
description: Plans
|
||||
goal: Plan
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-location.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
// Relative path should fail (process.exit is wrapped by vitest)
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
|
||||
).rejects.toThrow();
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start uses process.cwd() as default", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-default-cwd
|
||||
description: Test default cwd
|
||||
roles:
|
||||
planner:
|
||||
description: Plans
|
||||
goal: Plan
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-default-cwd.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test", tmpDir);
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
const headHash = index[result.thread as ThreadId];
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
|
||||
// Should default to process.cwd()
|
||||
expect(startPayload.cwd).toBe(process.cwd());
|
||||
|
||||
await teardown();
|
||||
});
|
||||
});
|
||||
@@ -51,11 +51,11 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "writer", prompt: "Begin writing" } },
|
||||
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } },
|
||||
$START: { _: { role: "writer", prompt: "Begin writing", location: null } },
|
||||
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done: {{{summary}}}" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
|
||||
approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -67,7 +67,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
|
||||
describe("Suite 1: Role Reference Integrity", () => {
|
||||
test("1.1 graph references unknown role", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
|
||||
});
|
||||
@@ -138,8 +138,8 @@ describe("Suite 2: Graph Structure", () => {
|
||||
test("2.2 $START has multiple status keys", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$START = {
|
||||
_: { role: "writer", prompt: "Begin" },
|
||||
other: { role: "reviewer", prompt: "Also" },
|
||||
_: { role: "writer", prompt: "Begin", location: null },
|
||||
other: { role: "reviewer", prompt: "Also", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -149,7 +149,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
|
||||
test("2.3 $START edge uses non-_ status", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } };
|
||||
wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
|
||||
@@ -158,7 +158,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
|
||||
test("2.4 $END has outgoing edges", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$END = { _: { role: "writer", prompt: "Loop" } };
|
||||
wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
required: ["$status"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.isolated = { _: { role: "$END", prompt: "done" } };
|
||||
wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
|
||||
true,
|
||||
@@ -186,7 +186,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
|
||||
test("2.6 edge target references invalid role", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } };
|
||||
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
|
||||
});
|
||||
@@ -196,8 +196,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
test("3.1 single-exit role with multiple graph keys", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = {
|
||||
_: { role: "reviewer", prompt: "Review" },
|
||||
extra: { role: "$END", prompt: "Done" },
|
||||
_: { role: "reviewer", prompt: "Review", location: null },
|
||||
extra: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -209,7 +209,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
|
||||
test("3.2 single-exit role missing _ key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } };
|
||||
wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
|
||||
@@ -219,9 +219,9 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
test("3.3 multi-exit role with extra statuses", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
rejected: { role: "writer", prompt: "Fix" },
|
||||
timeout: { role: "$END", prompt: "Timed out" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix", location: null },
|
||||
timeout: { role: "$END", prompt: "Timed out", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -232,7 +232,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
test("3.4 multi-exit role missing a status", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -242,7 +242,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
|
||||
test("3.5 multi-exit role with _ key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } };
|
||||
wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
|
||||
true,
|
||||
@@ -265,8 +265,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
@@ -286,9 +286,9 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
rejected: { role: "writer", prompt: "Fix" },
|
||||
timeout: { role: "$END", prompt: "Timed out" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix", location: null },
|
||||
timeout: { role: "$END", prompt: "Timed out", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
|
||||
@@ -308,7 +308,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
|
||||
@@ -327,7 +327,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
required: ["$status", "plan"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -346,8 +346,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
|
||||
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
|
||||
@@ -357,7 +357,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||
test("4.1 prompt references nonexistent variable (single-exit)", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) =>
|
||||
@@ -369,8 +369,8 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||
test("4.2 prompt references nonexistent variable (multi-exit)", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done: {{{branch}}}" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
|
||||
approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -388,7 +388,7 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||
|
||||
test("4.4 $status variable is always valid", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -461,9 +461,9 @@ describe("Suite 6: Multiple Errors Collection", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
// unknown graph reference
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
|
||||
// bad mustache var
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
@@ -41,8 +41,8 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "start working" } },
|
||||
worker: { _: { role: "$END", prompt: "done" } },
|
||||
$START: { _: { role: "worker", prompt: "start working", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "done", location: null } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,10 +17,15 @@ import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js"
|
||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import {
|
||||
cmdSkillActor,
|
||||
cmdSkillAdapter,
|
||||
cmdSkillArchitecture,
|
||||
cmdSkillAuthor,
|
||||
cmdSkillCli,
|
||||
cmdSkillDeveloper,
|
||||
cmdSkillList,
|
||||
cmdSkillModerator,
|
||||
cmdSkillUser,
|
||||
cmdSkillYaml,
|
||||
} from "./commands/skill.js";
|
||||
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||
@@ -503,6 +508,34 @@ skill
|
||||
console.log(cmdSkillYaml());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("actor")
|
||||
.description("Print the actor reference (frontmatter protocol + CAS)")
|
||||
.action(() => {
|
||||
console.log(cmdSkillActor());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("adapter")
|
||||
.description("Print the adapter reference (building agent adapters)")
|
||||
.action(() => {
|
||||
console.log(cmdSkillAdapter());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("author")
|
||||
.description("Print the author reference (workflow YAML design guide)")
|
||||
.action(() => {
|
||||
console.log(cmdSkillAuthor());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("developer")
|
||||
.description("Print the developer reference (coding conventions + architecture)")
|
||||
.action(() => {
|
||||
console.log(cmdSkillDeveloper());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("moderator")
|
||||
.description("Print the moderator reference")
|
||||
@@ -510,6 +543,13 @@ skill
|
||||
console.log(cmdSkillModerator());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("user")
|
||||
.description("Print the user reference (CLI guide + typical workflows)")
|
||||
.action(() => {
|
||||
console.log(cmdSkillUser());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("list")
|
||||
.description("List all available skill names")
|
||||
@@ -524,7 +564,7 @@ program
|
||||
.option("--base-url <url>", "OpenAI-compatible API base URL")
|
||||
.option("--api-key <key>", "API key")
|
||||
.option("--model <name>", "Default model name")
|
||||
.option("--agent <name>", "Default agent alias")
|
||||
.option("--agent <name>", "Default agent adapter (e.g. hermes → uwf-hermes)")
|
||||
.action(
|
||||
(opts: {
|
||||
provider?: string;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { parse, stringify } from "yaml";
|
||||
/**
|
||||
* Valid configuration key schema
|
||||
*/
|
||||
const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[] }> = {
|
||||
const VALID_CONFIG_KEYS: Record<
|
||||
string,
|
||||
{ nested: boolean; knownFields?: string[]; minDepth?: number }
|
||||
> = {
|
||||
providers: {
|
||||
nested: true,
|
||||
knownFields: ["baseUrl", "apiKey"],
|
||||
@@ -18,6 +21,17 @@ const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[
|
||||
nested: true,
|
||||
knownFields: ["command", "args"],
|
||||
},
|
||||
agentOverrides: {
|
||||
nested: true,
|
||||
// agentOverrides.<workflowName>.<roleName> = agentAlias (string value)
|
||||
// No knownFields — workflow/role names are user-defined
|
||||
},
|
||||
modelOverrides: {
|
||||
nested: true,
|
||||
minDepth: 2,
|
||||
// modelOverrides.<scenario> = modelAlias (string value)
|
||||
// No knownFields — scenarios are user-defined
|
||||
},
|
||||
defaultAgent: { nested: false },
|
||||
defaultModel: { nested: false },
|
||||
};
|
||||
@@ -43,8 +57,9 @@ function validateConfigKey(path: string[]): void {
|
||||
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
|
||||
}
|
||||
|
||||
// Nested keys must have at least 3 segments (e.g., providers.myProvider.baseUrl)
|
||||
if (schema.nested && path.length < 3) {
|
||||
// Nested keys must have at least minDepth segments (default 3)
|
||||
const minDepth = schema.minDepth ?? 3;
|
||||
if (schema.nested && path.length < minDepth) {
|
||||
const fields = schema.knownFields?.join(", ") ?? "";
|
||||
throw new Error(
|
||||
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
export {
|
||||
generateActorReference as cmdSkillActor,
|
||||
generateAdapterReference as cmdSkillAdapter,
|
||||
generateArchitectureReference as cmdSkillArchitecture,
|
||||
generateAuthorReference as cmdSkillAuthor,
|
||||
generateCliReference as cmdSkillCli,
|
||||
generateDeveloperReference as cmdSkillDeveloper,
|
||||
generateModeratorReference as cmdSkillModerator,
|
||||
generateUserReference as cmdSkillUser,
|
||||
generateYamlReference as cmdSkillYaml,
|
||||
} from "@uncaged/workflow-util";
|
||||
|
||||
const SKILL_NAMES = ["cli", "architecture", "yaml", "moderator"] as const;
|
||||
const SKILL_NAMES = [
|
||||
"cli",
|
||||
"architecture",
|
||||
"yaml",
|
||||
"moderator",
|
||||
"actor",
|
||||
"user",
|
||||
"author",
|
||||
"developer",
|
||||
"adapter",
|
||||
] as const;
|
||||
|
||||
export function cmdSkillList(): ReadonlyArray<string> {
|
||||
return [...SKILL_NAMES];
|
||||
|
||||
@@ -266,7 +266,13 @@ export async function cmdThreadStart(
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
projectRoot: string,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<StartOutput> {
|
||||
// Validate cwd is an absolute path
|
||||
if (!isAbsolute(cwd)) {
|
||||
fail("cwd must be an absolute path");
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
|
||||
|
||||
@@ -278,6 +284,7 @@ export async function cmdThreadStart(
|
||||
const startPayload: StartNodePayload = {
|
||||
workflow: workflowHash,
|
||||
prompt,
|
||||
cwd,
|
||||
};
|
||||
|
||||
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||
@@ -772,6 +779,7 @@ function spawnAgent(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
cwd: string,
|
||||
): CasRef {
|
||||
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
|
||||
let stdout: string;
|
||||
@@ -780,6 +788,7 @@ function spawnAgent(
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
|
||||
cwd,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
|
||||
@@ -987,6 +996,11 @@ async function cmdThreadStepOnce(
|
||||
|
||||
const role = nextResult.value.role;
|
||||
const edgePrompt = nextResult.value.prompt;
|
||||
|
||||
// Resolve cwd: use edge location if provided, otherwise inherit thread.cwd
|
||||
const threadCwd = chain.start.cwd;
|
||||
const effectiveCwd = nextResult.value.location !== null ? nextResult.value.location : threadCwd;
|
||||
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
|
||||
@@ -995,7 +1009,7 @@ async function cmdThreadStepOnce(
|
||||
});
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt);
|
||||
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
|
||||
|
||||
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ function normalizeGraph(
|
||||
normalized[status] = {
|
||||
role: target.role,
|
||||
prompt: target.prompt,
|
||||
location: target.location ?? null,
|
||||
};
|
||||
}
|
||||
result[node] = normalized;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { evaluate } from "../evaluate.js";
|
||||
|
||||
describe("Edge prompt template variable resolution", () => {
|
||||
test("returns error when rendered prompt is empty string", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("prompt");
|
||||
expect(result.error.message).toContain("empty");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns error when rendered prompt is whitespace-only", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("prompt");
|
||||
expect(result.error.message).toContain("empty");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds when all template variables resolve to non-empty values", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", { userPrompt: "Fix the bug" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Fix the bug");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds with static (no-variable) prompt", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "Classify this input", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Classify this input");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds when prompt has mix of static text and unresolved variables", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Please handle: ");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns error when ALL variables missing and no static text remains", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Moderator location resolution", () => {
|
||||
test("returns null location when edge has no location field", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves static location string", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "/static/path",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe("/static/path");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves mustache template location", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", {
|
||||
$status: "ready",
|
||||
repoPath: "/home/user/repo",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe("/home/user/repo");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves mustache template with multiple variables", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{basePath}}}/{{{projectName}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", {
|
||||
$status: "ready",
|
||||
basePath: "/home/user",
|
||||
projectName: "myproject",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe("/home/user/myproject");
|
||||
}
|
||||
});
|
||||
|
||||
test("handles missing template variable gracefully", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
// Mustache renders missing variables as empty string
|
||||
expect(result.value.location).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,16 @@ export function evaluate(
|
||||
|
||||
try {
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
return { ok: true, value: { role: target.role, prompt } };
|
||||
if (prompt.trim() === "") {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`edge prompt resolved to empty string for role "${target.role}" (template: "${target.prompt}"). Check that upstream output includes required variables.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
const location = target.location !== null ? mustache.render(target.location, lastOutput) : null;
|
||||
return { ok: true, value: { role: target.role, prompt, location } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -4,4 +4,6 @@ export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||
export type EvaluateResult = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
/** Resolved working directory from edge location field (null = inherit thread cwd). */
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
@@ -36,8 +36,13 @@ function isTarget(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const hasValidLocation =
|
||||
value.location === undefined || value.location === null || typeof value.location === "string";
|
||||
return (
|
||||
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||
typeof value.role === "string" &&
|
||||
typeof value.prompt === "string" &&
|
||||
value.prompt.trim() !== "" &&
|
||||
hasValidLocation
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,5 +100,22 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
|
||||
// Normalize location field: undefined → null
|
||||
const normalized = { ...raw } as WorkflowPayload;
|
||||
for (const roleName of Object.keys(normalized.graph)) {
|
||||
const statusMap = normalized.graph[roleName];
|
||||
if (statusMap !== undefined) {
|
||||
for (const status of Object.keys(statusMap)) {
|
||||
const target = statusMap[status];
|
||||
if (target !== undefined) {
|
||||
if (target.location === undefined) {
|
||||
target.location = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail.
|
||||
`uwf-hermes` — an **agent adapter** that bridges the `uwf` workflow engine and the Hermes CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
|
||||
`uwf-hermes` is an adapter (not the Hermes CLI itself). The `uwf` engine speaks a generic agent protocol (stdin/stdout frontmatter contract); `uwf-hermes` translates that protocol into Hermes ACP (Agent Client Protocol) calls. Other adapters (e.g. `uwf-claude-code`, `uwf-cursor`) do the same for their respective CLIs.
|
||||
|
||||
On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PKG_ROOT = join(import.meta.dir, "..");
|
||||
|
||||
describe("Issue #551 — bin entry & engines", () => {
|
||||
test("package.json declares bun in engines", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
|
||||
expect(pkg.engines).toBeDefined();
|
||||
expect(pkg.engines.bun).toBeDefined();
|
||||
expect(pkg.engines.bun).toMatch(/^>=?\s*[\d.]+/);
|
||||
});
|
||||
|
||||
test("bin entry file has bun shebang", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
|
||||
const binPath = pkg.bin["uwf-hermes"];
|
||||
const content = readFileSync(join(PKG_ROOT, binPath), "utf-8");
|
||||
expect(content.startsWith("#!/usr/bin/env bun")).toBe(true);
|
||||
});
|
||||
|
||||
test("README.md explains uwf-hermes is an adapter", () => {
|
||||
const readme = readFileSync(join(PKG_ROOT, "README.md"), "utf-8");
|
||||
expect(readme.toLowerCase()).toContain("adapter");
|
||||
expect(readme).toMatch(/uwf-hermes/);
|
||||
expect(readme).toMatch(/hermes/);
|
||||
});
|
||||
});
|
||||
@@ -42,5 +42,8 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">= 1.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { StartNodePayload, StepRecord, Target } from "../types.js";
|
||||
|
||||
describe("Protocol types for thread/edge location", () => {
|
||||
describe("StartNodePayload", () => {
|
||||
test("has required cwd field", () => {
|
||||
const payload: StartNodePayload = {
|
||||
workflow: "0123456789ABC",
|
||||
prompt: "Test prompt",
|
||||
cwd: "/home/user/project",
|
||||
};
|
||||
|
||||
expect(payload.cwd).toBe("/home/user/project");
|
||||
expect(typeof payload.cwd).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StepRecord", () => {
|
||||
test("has required cwd field", () => {
|
||||
const record: StepRecord = {
|
||||
role: "planner",
|
||||
output: "0123456789ABC",
|
||||
detail: "DEF0123456789",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Plan the implementation",
|
||||
startedAtMs: Date.now(),
|
||||
completedAtMs: Date.now() + 1000,
|
||||
cwd: "/home/user/project",
|
||||
};
|
||||
|
||||
expect(record.cwd).toBe("/home/user/project");
|
||||
expect(typeof record.cwd).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Target", () => {
|
||||
test("has location field that accepts string", () => {
|
||||
const target: Target = {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "/custom/path",
|
||||
};
|
||||
|
||||
expect(target.location).toBe("/custom/path");
|
||||
expect(typeof target.location).toBe("string");
|
||||
});
|
||||
|
||||
test("has location field that accepts null", () => {
|
||||
const target: Target = {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: null,
|
||||
};
|
||||
|
||||
expect(target.location).toBe(null);
|
||||
});
|
||||
|
||||
test("location supports mustache template syntax", () => {
|
||||
const target: Target = {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
};
|
||||
|
||||
expect(target.location).toBe("{{{repoPath}}}");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,9 @@ const TARGET: JSONSchema = {
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
location: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -49,10 +52,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
export const START_NODE_SCHEMA: JSONSchema = {
|
||||
title: "StartNode",
|
||||
type: "object",
|
||||
required: ["workflow", "prompt"],
|
||||
required: ["workflow", "prompt", "cwd"],
|
||||
properties: {
|
||||
workflow: { type: "string", format: "cas_ref" },
|
||||
prompt: { type: "string" },
|
||||
cwd: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -60,7 +64,17 @@ export const START_NODE_SCHEMA: JSONSchema = {
|
||||
export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||
title: "StepNode",
|
||||
type: "object",
|
||||
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
|
||||
required: [
|
||||
"start",
|
||||
"prev",
|
||||
"role",
|
||||
"output",
|
||||
"detail",
|
||||
"agent",
|
||||
"startedAtMs",
|
||||
"completedAtMs",
|
||||
"cwd",
|
||||
],
|
||||
properties: {
|
||||
start: { type: "string", format: "cas_ref" },
|
||||
prev: {
|
||||
@@ -73,6 +87,7 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||
edgePrompt: { type: "string" },
|
||||
startedAtMs: { type: "integer" },
|
||||
completedAtMs: { type: "integer" },
|
||||
cwd: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ export type StepRecord = {
|
||||
startedAtMs: number;
|
||||
/** Date.now() after agent returns */
|
||||
completedAtMs: number;
|
||||
/** Working directory where the agent executed. Missing in legacy nodes → "". */
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
|
||||
@@ -34,6 +36,8 @@ export type RoleDefinition = {
|
||||
export type Target = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
/** Optional working directory override via mustache template. */
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowPayload = {
|
||||
@@ -48,6 +52,8 @@ export type WorkflowPayload = {
|
||||
export type StartNodePayload = {
|
||||
workflow: CasRef;
|
||||
prompt: string;
|
||||
/** Working directory where the thread was created. */
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type StepNodePayload = StepRecord & {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
describe("parseArgv empty prompt error message", () => {
|
||||
let stderrOutput: string;
|
||||
let _exitCode: number | null;
|
||||
const originalExit = process.exit;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
beforeEach(() => {
|
||||
stderrOutput = "";
|
||||
_exitCode = null;
|
||||
process.exit = ((code?: number) => {
|
||||
_exitCode = code ?? 1;
|
||||
throw new Error("process.exit called");
|
||||
}) as any;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrOutput += chunk;
|
||||
return true;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalExit;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
});
|
||||
|
||||
test("empty prompt produces error message mentioning template variables", async () => {
|
||||
const { parseArgv } = await import("../run.js");
|
||||
const argv = [
|
||||
"node",
|
||||
"uwf-hermes",
|
||||
"--thread",
|
||||
"01ABCDEFGHIJKLMNOPQRSTUVWX",
|
||||
"--role",
|
||||
"classifier",
|
||||
"--prompt",
|
||||
"",
|
||||
];
|
||||
|
||||
expect(() => parseArgv(argv)).toThrow("process.exit called");
|
||||
expect(stderrOutput).toContain("prompt");
|
||||
expect(stderrOutput).toContain("empty");
|
||||
expect(stderrOutput).toContain("template");
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,7 @@ async function buildHistory(
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
startedAtMs: step.startedAtMs,
|
||||
completedAtMs: step.completedAtMs,
|
||||
cwd: step.cwd ?? "",
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export {
|
||||
} from "./extract.js";
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { createAgent, parseArgv } from "./run.js";
|
||||
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export type {
|
||||
|
||||
@@ -32,13 +32,16 @@ function getNamedArg(argv: string[], name: string): string {
|
||||
return argv[idx + 1];
|
||||
}
|
||||
|
||||
function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
|
||||
export function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
|
||||
const threadId = getNamedArg(argv, "--thread");
|
||||
const role = getNamedArg(argv, "--role");
|
||||
const prompt = getNamedArg(argv, "--prompt");
|
||||
if (threadId === "") fail(USAGE);
|
||||
if (role === "") fail(USAGE);
|
||||
if (prompt === "") fail(USAGE);
|
||||
if (prompt === "")
|
||||
fail(
|
||||
`--prompt is empty. If this agent was spawned by uwf, the edge prompt template may have unresolved variables. ${USAGE}`,
|
||||
);
|
||||
return { threadId: threadId as ThreadId, role, prompt };
|
||||
}
|
||||
|
||||
@@ -72,6 +75,7 @@ async function writeStepNode(options: {
|
||||
edgePrompt: options.edgePrompt,
|
||||
startedAtMs: options.startedAtMs,
|
||||
completedAtMs: options.completedAtMs,
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
const hash = await options.store.put(options.schemas.stepNode, payload);
|
||||
const node = options.store.get(hash);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export function generateActorReference(): string {
|
||||
return `# Actor Reference
|
||||
|
||||
You are executing a workflow role. Your system prompt defines your goal, procedure, and output requirements. This reference covers two things you need to know about the workflow engine.
|
||||
|
||||
## 1. Frontmatter Output Protocol
|
||||
|
||||
Your response **MUST** begin with a YAML frontmatter block at byte position 0 — no preamble text before it.
|
||||
|
||||
\`\`\`
|
||||
---
|
||||
status: done
|
||||
myField: some value
|
||||
---
|
||||
|
||||
... markdown body (your work, explanation, notes) ...
|
||||
\`\`\`
|
||||
|
||||
### Standard Field
|
||||
|
||||
| Field | Values | Default | Description |
|
||||
|-------|--------|---------|-------------|
|
||||
| \`status\` | \`done\`, \`needs_input\`, \`in_progress\`, \`failed\` | \`done\` | Completion signal — determines which graph edge the moderator follows next |
|
||||
|
||||
### Schema-Defined Fields
|
||||
|
||||
Your role's output schema (shown in the system prompt under "Deliverable Format") defines additional fields. Output **only** the fields listed there — do not invent extra fields.
|
||||
|
||||
### Body
|
||||
|
||||
Everything after the closing \`---\` fence is the markdown body. Use it for explanations, logs, or human-readable notes. The body is stored but not parsed by the engine.
|
||||
|
||||
### Retry
|
||||
|
||||
If the engine cannot parse your frontmatter, it will ask you to retry (up to 2 times). Just output the corrected frontmatter block — don't panic.
|
||||
|
||||
## 2. CAS (Content-Addressable Store)
|
||||
|
||||
Your frontmatter output is automatically stored in CAS. You can also **use CAS directly** to store intermediate artifacts, build merkle DAGs for large outputs, or reference data from previous steps.
|
||||
|
||||
### Commands
|
||||
|
||||
\`\`\`
|
||||
uwf cas put-text <text> # store plain text, print hash
|
||||
uwf cas put <type-hash> <json> # store typed JSON data, print hash
|
||||
uwf cas get <hash> # read a CAS node (type + payload)
|
||||
uwf cas has <hash> # check if a hash exists
|
||||
uwf cas refs <hash> # list direct references from a node
|
||||
uwf cas walk <hash> # recursive traversal from a node
|
||||
uwf cas schema list # list registered schemas
|
||||
uwf cas schema get <hash> # show a schema definition
|
||||
\`\`\`
|
||||
|
||||
### Merkle DAG Pattern
|
||||
|
||||
For large outputs, store parts individually and reference their hashes:
|
||||
|
||||
\`\`\`bash
|
||||
# Store individual sections
|
||||
HASH1=$(uwf cas put-text "section 1 content")
|
||||
HASH2=$(uwf cas put-text "section 2 content")
|
||||
|
||||
# Reference hashes in your frontmatter or in a parent node
|
||||
\`\`\`
|
||||
|
||||
This enables progressive loading — consumers can fetch the root and resolve children on demand.
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
export function generateAdapterReference(): string {
|
||||
return `# Adapter Reference
|
||||
|
||||
Guide for building a new agent adapter (CLI binary) for the workflow engine.
|
||||
|
||||
## What Is an Adapter
|
||||
|
||||
An adapter is a CLI command (e.g. \`uwf-hermes\`, \`uwf-builtin\`) that the engine spawns to execute a role. It bridges the workflow engine and an LLM/agent backend. The engine calls it with:
|
||||
|
||||
\`\`\`
|
||||
uwf-<name> --thread <id> --role <role> --prompt <text>
|
||||
\`\`\`
|
||||
|
||||
The adapter must produce frontmatter markdown output. The engine handles argument parsing, context building, output extraction, and CAS persistence — you just implement the LLM interaction.
|
||||
|
||||
## Quick Start
|
||||
|
||||
\`\`\`typescript
|
||||
import { createAgent } from "@uncaged/workflow-util-agent";
|
||||
import type { AgentContext, AgentRunResult, AgentContinueFn, AgentRunFn } from "@uncaged/workflow-util-agent";
|
||||
|
||||
const run: AgentRunFn = async (ctx: AgentContext): Promise<AgentRunResult> => {
|
||||
// 1. Build your prompt from ctx
|
||||
// 2. Call your LLM backend
|
||||
// 3. Return the result
|
||||
return { output: rawMarkdown, detailHash, sessionId };
|
||||
};
|
||||
|
||||
const continue_: AgentContinueFn = async (sessionId, message, store) => {
|
||||
// Resume an existing session with a correction message
|
||||
return { output: correctedMarkdown, detailHash, sessionId };
|
||||
};
|
||||
|
||||
const main = createAgent({ name: "my-agent", run, continue: continue_ });
|
||||
main();
|
||||
\`\`\`
|
||||
|
||||
## The \`createAgent\` Factory
|
||||
|
||||
\`createAgent(options)\` returns an async \`main()\` function that handles the full lifecycle:
|
||||
|
||||
1. Parses CLI args (\`--thread\`, \`--role\`, \`--prompt\`)
|
||||
2. Loads \`.env\` from storage root
|
||||
3. Builds \`AgentContext\` (thread history, workflow definition, role prompt)
|
||||
4. Injects \`outputFormatInstruction\` from the role's frontmatter schema
|
||||
5. Calls your \`run(ctx)\` function
|
||||
6. Extracts frontmatter from your output via \`tryFrontmatterFastPath()\`
|
||||
7. If extraction fails, calls your \`continue(sessionId, correctionMessage, store)\` up to 2 times
|
||||
8. Persists the validated output as a CAS step node
|
||||
9. Prints the step hash to stdout
|
||||
|
||||
You only implement \`run\` and \`continue\`.
|
||||
|
||||
## AgentOptions
|
||||
|
||||
\`\`\`typescript
|
||||
type AgentOptions = {
|
||||
name: string; // Adapter name (used in step records as "uwf-<name>")
|
||||
run: AgentRunFn; // Execute a role from scratch
|
||||
continue: AgentContinueFn; // Resume a session for frontmatter correction
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## AgentContext
|
||||
|
||||
The \`ctx\` object passed to your \`run\` function:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| \`threadId\` | \`string\` | Thread ULID |
|
||||
| \`role\` | \`string\` | Role name being executed |
|
||||
| \`edgePrompt\` | \`string\` | Moderator's task instruction for this step |
|
||||
| \`workflow\` | \`WorkflowPayload\` | Full workflow definition (roles, graph) |
|
||||
| \`start\` | \`StartNodePayload\` | Thread start data (workflow hash, user prompt) |
|
||||
| \`steps\` | \`StepContext[]\` | Previous steps with expanded outputs |
|
||||
| \`store\` | \`Store\` | CAS store for reading/writing data |
|
||||
| \`outputFormatInstruction\` | \`string\` | Frontmatter format instruction (inject into system prompt) |
|
||||
| \`isFirstVisit\` | \`boolean\` | True if this role hasn't run before in this thread |
|
||||
|
||||
## AgentRunResult
|
||||
|
||||
Your \`run\` and \`continue\` functions must return:
|
||||
|
||||
\`\`\`typescript
|
||||
type AgentRunResult = {
|
||||
output: string; // Raw markdown with frontmatter (must start with ---)
|
||||
detailHash: string; // CAS hash of session detail (turn history, metadata)
|
||||
sessionId: string; // Session ID for potential continue() calls
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## Building the Prompt
|
||||
|
||||
Use helpers from \`@uncaged/workflow-util-agent\`:
|
||||
|
||||
| Helper | Purpose |
|
||||
|--------|---------|
|
||||
| \`buildRolePrompt(roleDef)\` | Assemble Goal/Capabilities/Prepare/Procedure/Output sections |
|
||||
| \`buildContinuationPrompt(steps, role, edgePrompt)\` | For re-entry: steps since last visit + edge prompt |
|
||||
| \`ctx.outputFormatInstruction\` | Pre-built frontmatter format block (inject into system prompt) |
|
||||
|
||||
Typical system prompt structure:
|
||||
\`\`\`
|
||||
[outputFormatInstruction]
|
||||
[rolePrompt from buildRolePrompt()]
|
||||
[workflow metadata]
|
||||
\`\`\`
|
||||
|
||||
## Storing Session Detail
|
||||
|
||||
Store your turn history as a CAS merkle DAG for debugging and replay:
|
||||
|
||||
\`\`\`typescript
|
||||
// Store each turn as a CAS text node
|
||||
const turnHash = await store.put(textSchema, { content: turnData });
|
||||
|
||||
// Build a detail node referencing all turns
|
||||
const detailHash = await store.put(detailSchema, { turns: turnHashes });
|
||||
\`\`\`
|
||||
|
||||
The \`detailHash\` is preserved from the first \`run()\` call — retry \`continue()\` calls don't overwrite it.
|
||||
|
||||
## Registration
|
||||
|
||||
Register your adapter in \`~/.uncaged/workflow/config.yaml\`:
|
||||
|
||||
\`\`\`yaml
|
||||
agents:
|
||||
my-agent:
|
||||
command: uwf-my-agent
|
||||
args: []
|
||||
\`\`\`
|
||||
|
||||
Use it:
|
||||
\`\`\`bash
|
||||
uwf thread exec <thread-id> --agent my-agent
|
||||
\`\`\`
|
||||
|
||||
Or set as default:
|
||||
\`\`\`yaml
|
||||
defaultAgent: my-agent
|
||||
\`\`\`
|
||||
|
||||
## Existing Adapters
|
||||
|
||||
| Adapter | Package | Backend |
|
||||
|---------|---------|---------|
|
||||
| \`uwf-hermes\` | \`@uncaged/workflow-agent-hermes\` | Hermes ACP (chat sessions) |
|
||||
| \`uwf-builtin\` | \`@uncaged/workflow-agent-builtin\` | Direct OpenAI API (tools + loop) |
|
||||
| \`uwf-claude-code\` | \`@uncaged/workflow-agent-claude-code\` | Claude Code CLI |
|
||||
|
||||
Study these for patterns on prompt building, session management, and detail storage.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Implement \`run(ctx)\` — build prompt, call LLM, return output + detailHash + sessionId
|
||||
2. Implement \`continue(sessionId, message, store)\` — resume session for frontmatter correction
|
||||
3. Store session detail as CAS nodes (for debugging)
|
||||
4. Ensure output starts with \`---\` frontmatter block
|
||||
5. Add a \`bin\` entry in \`package.json\` for the CLI command
|
||||
6. Register in config.yaml and test with \`uwf thread exec --agent <name>\`
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
export function generateAuthorReference(): string {
|
||||
return `# Author Reference
|
||||
|
||||
Guide for designing and writing workflow YAML definitions.
|
||||
|
||||
## Workflow Structure
|
||||
|
||||
\`\`\`yaml
|
||||
name: solve-issue # verb-first kebab-case
|
||||
description: "..." # human-readable summary
|
||||
|
||||
roles: # named actors
|
||||
planner:
|
||||
description: "..." # short purpose
|
||||
goal: "..." # system-level goal for the agent
|
||||
capabilities: [...] # skill keywords the agent should load
|
||||
procedure: | # step-by-step instructions
|
||||
1. Do this
|
||||
2. Do that
|
||||
output: "..." # what the agent should produce
|
||||
frontmatter: # JSON Schema for structured output
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
required: [$status, plan]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
graph: # status-based routing
|
||||
$START:
|
||||
_: { role: planner, prompt: "Analyze the issue." }
|
||||
planner:
|
||||
ready: { role: developer, prompt: "Implement {{{plan}}}." }
|
||||
failed: { role: $END, prompt: "Failed: {{{error}}}" }
|
||||
\`\`\`
|
||||
|
||||
## Role Definition
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| \`description\` | Short description for humans and moderator context |
|
||||
| \`goal\` | Injected as the agent's system-level objective |
|
||||
| \`capabilities\` | Keyword tags — agent loads matching skills before starting |
|
||||
| \`procedure\` | Step-by-step instructions the agent follows |
|
||||
| \`output\` | Describes what to produce and which \`$status\` values to use |
|
||||
| \`frontmatter\` | JSON Schema defining the structured output fields |
|
||||
|
||||
### Role Design Principles
|
||||
|
||||
- **Single responsibility** — each role does one thing well
|
||||
- **Minimal context** — don't overload a role with too many steps; split if needed
|
||||
- **Clear status values** — each status should map to a distinct graph edge
|
||||
- **Explicit output** — tell the agent exactly what \`$status\` values are valid
|
||||
|
||||
## Frontmatter Schema
|
||||
|
||||
The \`frontmatter\` field is a standard JSON Schema. It defines the structured fields the agent must output in YAML frontmatter.
|
||||
|
||||
### \`$status\` Field
|
||||
|
||||
\`$status\` is the only standard field. Its value determines which graph edge the moderator follows. Use \`const\` to constrain each variant:
|
||||
|
||||
\`\`\`yaml
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
result: { type: string }
|
||||
required: [$status, result]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
\`\`\`
|
||||
|
||||
### Custom Fields
|
||||
|
||||
Add any fields you need for data passing between roles. These are available in edge prompts via Mustache templates.
|
||||
|
||||
### Flat Schema (Single Status)
|
||||
|
||||
When a role has only one outcome:
|
||||
|
||||
\`\`\`yaml
|
||||
frontmatter:
|
||||
properties:
|
||||
$status: { const: "done" }
|
||||
summary: { type: string }
|
||||
required: [$status, summary]
|
||||
\`\`\`
|
||||
|
||||
## Graph Routing
|
||||
|
||||
The graph maps each role's \`$status\` values to the next role:
|
||||
|
||||
\`\`\`
|
||||
graph[role][$status] → { role: nextRole, prompt: edgePrompt }
|
||||
\`\`\`
|
||||
|
||||
### Special Nodes
|
||||
|
||||
| Node | Purpose |
|
||||
|------|---------|
|
||||
| \`$START\` | Entry point — status key is always \`_\` (unconditional) |
|
||||
| \`$END\` | Terminal — thread completes and is archived |
|
||||
|
||||
### Edge Prompts
|
||||
|
||||
Use triple-brace Mustache (\`{{{field}}}\`) to pass data from the previous step's output:
|
||||
|
||||
\`\`\`yaml
|
||||
graph:
|
||||
planner:
|
||||
ready: { role: developer, prompt: "Implement plan {{{plan}}} in {{{repoPath}}}." }
|
||||
\`\`\`
|
||||
|
||||
The fields referenced must exist in the source role's frontmatter schema.
|
||||
|
||||
### Loops and Branching
|
||||
|
||||
Roles can route back to previous roles (loops) or to different roles based on status (branching):
|
||||
|
||||
\`\`\`yaml
|
||||
graph:
|
||||
reviewer:
|
||||
approved: { role: tester, prompt: "Run tests." }
|
||||
rejected: { role: developer, prompt: "Fix: {{{comments}}}" } # loop back
|
||||
\`\`\`
|
||||
|
||||
### Fail Routing
|
||||
|
||||
Route failures to a cleanup role or \`$END\`:
|
||||
|
||||
\`\`\`yaml
|
||||
graph:
|
||||
developer:
|
||||
done: { role: reviewer, prompt: "Review changes." }
|
||||
failed: { role: cleanup, prompt: "Clean up: {{{error}}}" }
|
||||
\`\`\`
|
||||
|
||||
## Self-Testing
|
||||
|
||||
### Step-by-Step Verification
|
||||
|
||||
\`\`\`bash
|
||||
# Start a thread directly from YAML file (no registration needed)
|
||||
uwf thread start my-workflow.yaml -p "Test prompt"
|
||||
|
||||
# Or register first, then start by name
|
||||
uwf workflow add my-workflow.yaml
|
||||
uwf thread start my-workflow -p "Test prompt"
|
||||
|
||||
# Execute one step at a time to verify routing
|
||||
uwf thread exec <thread-id>
|
||||
|
||||
# Inspect step output
|
||||
uwf step list <thread-id>
|
||||
uwf step show <step-hash>
|
||||
|
||||
# Check the CAS data
|
||||
uwf cas get <output-hash>
|
||||
\`\`\`
|
||||
|
||||
### Validation Checklist
|
||||
|
||||
1. Every \`$status\` value in a role's frontmatter has a matching edge in the graph
|
||||
2. Every field referenced in edge prompts (\`{{{field}}}\`) exists in the source role's schema
|
||||
3. Every role referenced in the graph exists in \`roles\`
|
||||
4. \`$START\` has exactly one edge with key \`_\`
|
||||
5. At least one path leads to \`$END\`
|
||||
6. No orphan roles (defined but never routed to)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Missing graph edge** — if a role can produce \`$status: failed\` but the graph has no \`failed\` edge, the moderator will error
|
||||
- **Mustache field mismatch** — referencing \`{{{branch}}}\` in an edge prompt but the source schema has \`branchName\` instead
|
||||
- **Overly complex roles** — a role with 20 steps should be split; each role should be completable in one agent turn
|
||||
- **No fail path** — always handle failure; route to cleanup or \`$END\`
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export function generateDeveloperReference(): string {
|
||||
return `# Developer Reference
|
||||
|
||||
Guide for contributing to the workflow engine codebase.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
\`\`\`
|
||||
packages/
|
||||
workflow-protocol/ # Shared types (WorkflowPayload, StepNodePayload, etc.)
|
||||
workflow-util/ # Base32, ULID, logger, frontmatter parsing, skill references
|
||||
workflow-util-agent/ # createAgent factory, context builder, extract pipeline
|
||||
workflow-agent-hermes/ # uwf-hermes CLI (spawns Hermes chat sessions)
|
||||
workflow-agent-builtin/ # uwf-builtin CLI (direct LLM calls via OpenAI API)
|
||||
cli-workflow/ # uwf CLI (moderator, thread/step/cas/config commands)
|
||||
\`\`\`
|
||||
|
||||
Dependency layers (each only imports from packages above it):
|
||||
\`\`\`
|
||||
protocol → util → util-agent → agent-hermes / agent-builtin / cli-workflow
|
||||
\`\`\`
|
||||
|
||||
External CAS: \`@uncaged/json-cas\` (store API, hashing, schema validation) + \`@uncaged/json-cas-fs\` (filesystem backend).
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### Functional-first
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| \`type\` over \`interface\` | All type definitions use \`type\` |
|
||||
| \`function\` over \`class\` | Pure functions + closures, no class |
|
||||
| No \`this\` | Functions must not depend on \`this\` context |
|
||||
| No inheritance | No \`extends\`, \`implements\`, \`abstract\` |
|
||||
| No optional properties | Use \`T \\| null\` instead of \`?:\` |
|
||||
| Immutability first | Use \`Readonly<T>\`, \`as const\`, avoid mutation |
|
||||
|
||||
Classes allowed only when required by third-party libraries or for Error subclasses.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- \`Result<T, E>\` type for expected failures (\`ok\`/\`err\` constructors from \`@uncaged/workflow-util\`)
|
||||
- \`throw\` only for unrecoverable bugs
|
||||
- No try-catch for flow control
|
||||
|
||||
### Async
|
||||
|
||||
Always \`async/await\`, never \`.then()\` chains.
|
||||
|
||||
### Logging
|
||||
|
||||
\`console.*\` is banned (Biome \`noConsole\` rule). Use the structured logger:
|
||||
|
||||
\`\`\`typescript
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
const log = createLogger();
|
||||
log("4KNMR2PX", "Loading workflow..."); // 8-char Crockford Base32 tag
|
||||
\`\`\`
|
||||
|
||||
Each call site gets a unique hand-written tag. \`grep "4KNMR2PX"\` in logs → instant code location.
|
||||
|
||||
CLI package (\`@uncaged/cli-workflow\`) may use \`console.log\` for user-facing output with a biome-ignore comment.
|
||||
|
||||
### No Dynamic Import
|
||||
|
||||
No \`await import()\` in production code. Always static top-level \`import\`. Test files are exempt.
|
||||
|
||||
### Naming
|
||||
|
||||
- Workflow names: verb-first kebab-case (\`solve-issue\`, \`review-code\`)
|
||||
- IDs: Crockford Base32 — CAS hash (XXH64, 13-char), Thread ID (ULID, 26-char)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
\`\`\`bash
|
||||
bun install # install all workspace deps
|
||||
bun run build # tsc --build (all packages)
|
||||
bun run check # tsc + biome check + lint-log-tags
|
||||
bun run format # biome format --write
|
||||
bun test # run all tests
|
||||
\`\`\`
|
||||
|
||||
Before committing: \`bun run check\` + \`bun test\` must both pass.
|
||||
|
||||
### Testing
|
||||
|
||||
- \`cli-workflow\`: vitest
|
||||
- Other packages: \`bun test\`
|
||||
- Test files live in \`__tests__/\` directories
|
||||
|
||||
### Publishing
|
||||
|
||||
Fixed-mode versioning — all \`@uncaged/*\` packages share the same version number.
|
||||
|
||||
\`\`\`bash
|
||||
bun changeset # describe the change
|
||||
bun version # bump versions + changelogs
|
||||
bun release # build + test + publish to npmjs
|
||||
\`\`\`
|
||||
|
||||
## Key Modules
|
||||
|
||||
### Moderator (\`cli-workflow/src/moderator/\`)
|
||||
|
||||
Status-based graph evaluator. Reads \`graph[lastRole][output.$status]\` to determine the next role. Zero LLM cost.
|
||||
|
||||
### Extract Pipeline (\`workflow-util-agent/src/\`)
|
||||
|
||||
1. Agent produces frontmatter markdown
|
||||
2. \`parseFrontmatterMarkdown()\` extracts YAML frontmatter
|
||||
3. \`tryFrontmatterFastPath()\` validates against role's output schema
|
||||
4. If fast path fails, retries up to 2 times via agent continue
|
||||
5. Validated output stored as CAS node
|
||||
|
||||
### createAgent Factory (\`workflow-util-agent/src/run.ts\`)
|
||||
|
||||
Shared entry point for all agent CLIs. Handles:
|
||||
- Argument parsing (\`--thread\`, \`--role\`, \`--prompt\`)
|
||||
- Context building (thread history, workflow definition)
|
||||
- Output extraction and CAS persistence
|
||||
- Frontmatter retry loop
|
||||
|
||||
### CAS Integration
|
||||
|
||||
All data is CAS-addressed via \`@uncaged/json-cas\`:
|
||||
- \`store.put(schemaHash, data)\` → content hash
|
||||
- \`store.get(hash)\` → node
|
||||
- \`validate(store, node)\` → schema check
|
||||
- Schemas registered at workflow add time
|
||||
|
||||
## Commit Convention
|
||||
|
||||
\`\`\`
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: workflow | cli | moderator | util-agent | hermes | util | protocol
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
export { generateActorReference } from "./actor-reference.js";
|
||||
export { generateAdapterReference } from "./adapter-reference.js";
|
||||
export { generateArchitectureReference } from "./architecture-reference.js";
|
||||
export { generateAuthorReference } from "./author-reference.js";
|
||||
export { encodeUint64AsCrockford } from "./base32.js";
|
||||
export { generateCliReference } from "./cli-reference.js";
|
||||
export { generateDeveloperReference } from "./developer-reference.js";
|
||||
export { env } from "./env.js";
|
||||
export type {
|
||||
AgentFrontmatter,
|
||||
@@ -27,4 +31,5 @@ export { err, ok } from "./result.js";
|
||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
export type { LogFn, Result } from "./types.js";
|
||||
export { extractUlidTimestamp, generateUlid } from "./ulid.js";
|
||||
export { generateUserReference } from "./user-reference.js";
|
||||
export { generateYamlReference } from "./yaml-reference.js";
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export function generateUserReference(): string {
|
||||
return `# User Reference
|
||||
|
||||
Guide for using the uwf CLI to manage workflows and threads.
|
||||
|
||||
## Quick Start
|
||||
|
||||
\`\`\`bash
|
||||
# 1. Configure provider and model
|
||||
uwf setup
|
||||
|
||||
# 2. Register a workflow
|
||||
uwf workflow add my-workflow.yaml
|
||||
|
||||
# 3. Start a thread (creates but does not execute)
|
||||
uwf thread start my-workflow -p "Build a login page"
|
||||
|
||||
# 4. Execute the thread (runs moderator → agent → extract cycles)
|
||||
uwf thread exec <thread-id> # one step
|
||||
uwf thread exec <thread-id> -c 10 # up to 10 steps
|
||||
uwf thread exec <thread-id> -c 10 --background # run in background
|
||||
\`\`\`
|
||||
|
||||
## Concepts
|
||||
|
||||
- **Workflow** — YAML definition with roles and a routing graph; stored as a CAS node
|
||||
- **Thread** — A running instance of a workflow; a chain of step nodes in CAS
|
||||
- **Step** — One moderator → agent → extract cycle; contains the role's structured output
|
||||
- **CAS** — Content-addressable store; every artifact is hashed (XXH64, Crockford Base32)
|
||||
|
||||
## Setup
|
||||
|
||||
\`\`\`
|
||||
uwf setup # interactive wizard
|
||||
uwf setup --provider <name> --base-url <url> \\
|
||||
--api-key <key> --model <name> # non-interactive
|
||||
[--agent <name>] # optional default agent
|
||||
\`\`\`
|
||||
|
||||
Config is stored at \`~/.uncaged/workflow/config.yaml\`. Override storage root with \`UNCAGED_WORKFLOW_STORAGE_ROOT\`.
|
||||
|
||||
## Workflow Commands
|
||||
|
||||
\`\`\`
|
||||
uwf workflow add <file> # register from YAML file
|
||||
uwf workflow show <id> # show by name or CAS hash
|
||||
uwf workflow list # list all registered workflows
|
||||
\`\`\`
|
||||
|
||||
You can also pass a file path directly to \`uwf thread start\` without registering first.
|
||||
|
||||
## Thread Lifecycle
|
||||
|
||||
\`\`\`
|
||||
uwf thread start <workflow> -p <prompt> # create thread
|
||||
uwf thread exec <thread-id> # execute one step
|
||||
[--agent <cmd>] # override agent
|
||||
[-c, --count <n>] # run n steps
|
||||
[--background] # run in background
|
||||
uwf thread show <thread-id> # show head pointer
|
||||
uwf thread list # list all threads
|
||||
[--status <filter>] # idle, running, completed, cancelled, active (comma-separated)
|
||||
[--after <thread-id>] # pagination: after this thread
|
||||
[--before <thread-id>] # pagination: before this thread
|
||||
[--skip <n>] # skip first n results
|
||||
[--take <n>] # limit results
|
||||
uwf thread read <thread-id> # render context as markdown
|
||||
[--quota <chars>] # max output chars (default 4000)
|
||||
[--before <step-hash>] # pagination
|
||||
[--start] # include start step
|
||||
uwf thread stop <thread-id> # stop background execution
|
||||
uwf thread cancel <thread-id> # cancel and archive thread
|
||||
\`\`\`
|
||||
|
||||
### Typical Lifecycle
|
||||
|
||||
\`\`\`
|
||||
start → exec (repeat) → thread reaches $END → auto-completed
|
||||
→ or: cancel to abort
|
||||
\`\`\`
|
||||
|
||||
## Step Commands
|
||||
|
||||
\`\`\`
|
||||
uwf step list <thread-id> # list all steps
|
||||
uwf step show <step-hash> # show step details
|
||||
uwf step fork <step-hash> # fork thread from a step (branch)
|
||||
\`\`\`
|
||||
|
||||
Forking creates a new thread that shares history up to the fork point — useful for retrying from a known-good state.
|
||||
|
||||
## CAS Commands
|
||||
|
||||
\`\`\`
|
||||
uwf cas get <hash> # read a node (type + payload)
|
||||
[--timestamp] # include timestamp
|
||||
uwf cas put <type-hash> <data> # store typed JSON, print hash
|
||||
uwf cas put-text <text> # store plain text, print hash
|
||||
uwf cas has <hash> # check existence
|
||||
uwf cas refs <hash> # list direct references
|
||||
uwf cas walk <hash> # recursive traversal
|
||||
uwf cas reindex # rebuild type index
|
||||
uwf cas schema list # list schemas
|
||||
uwf cas schema get <hash> # show schema definition
|
||||
\`\`\`
|
||||
|
||||
## Log Commands
|
||||
|
||||
\`\`\`
|
||||
uwf log list # list log files
|
||||
uwf log show # show log entries
|
||||
[--thread <id>] # filter by thread
|
||||
[--process <pid>] # filter by process
|
||||
[--date <YYYY-MM-DD>] # filter by date
|
||||
uwf log clean --before <date> # delete old logs
|
||||
\`\`\`
|
||||
|
||||
## Global Options
|
||||
|
||||
\`\`\`
|
||||
uwf --format <json|yaml> # output format (default: json)
|
||||
uwf -V, --version # print version
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user