refactor: align package folder names with npm package names
CI / check (pull_request) Failing after 8m30s
CI / check (pull_request) Failing after 8m30s
Rename packages/ subdirectories to match their @united-workforce/* scope: cli-workflow → cli workflow-agent-builtin → agent-builtin workflow-agent-claude-code → agent-claude-code workflow-agent-hermes → agent-hermes workflow-dashboard → dashboard workflow-protocol → protocol workflow-util-agent → util-agent workflow-util → util Updated all tsconfig references, scripts, and active docs. Historical docs (docs/plans/, docs/superpowers/) left as-is. Closes #21
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# @united-workforce/util
|
||||
|
||||
Shared utilities: encoding, IDs, logging, frontmatter parsing, storage paths, and CLI reference generation.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 1 shared infrastructure used across CLI, agent-kit, and agent packages. Provides Crockford Base32 encoding, ULID generation, structured logging with fixed 8-char tags, frontmatter markdown parsing/validation, process-level debug logging, and helpers for the default workflow data directory.
|
||||
|
||||
**Dependencies:** none (standalone)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @united-workforce/util
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Encoding and IDs
|
||||
|
||||
```typescript
|
||||
function encodeUint64AsCrockford(value: bigint): string
|
||||
function generateUlid(nowMs: number): string
|
||||
function extractUlidTimestamp(ulid: string): number | null
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
```typescript
|
||||
function createLogger(options?: { sink: { kind: "stderr" } }): LogFn
|
||||
|
||||
type LogFn = (tag: string, message: string) => void
|
||||
// CreateLoggerOptions and LoggerSink are internal types
|
||||
```
|
||||
|
||||
### Process logger
|
||||
|
||||
```typescript
|
||||
function createProcessLogger(options: CreateProcessLoggerOptions): ProcessLogger
|
||||
|
||||
type ProcessLogger = {
|
||||
pid: string;
|
||||
log: ProcessLogFn;
|
||||
};
|
||||
|
||||
type ProcessLoggerContext = {
|
||||
thread: string | null;
|
||||
workflow: string | null;
|
||||
};
|
||||
|
||||
type CreateProcessLoggerOptions = {
|
||||
storageRoot: string | null;
|
||||
context: ProcessLoggerContext;
|
||||
};
|
||||
|
||||
type ProcessLogFn = (
|
||||
tag: string,
|
||||
msg: string,
|
||||
context: Record<string, string> | null,
|
||||
) => void;
|
||||
```
|
||||
|
||||
### Frontmatter markdown
|
||||
|
||||
```typescript
|
||||
function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
|
||||
function validateFrontmatter(
|
||||
parsed: ParsedFrontmatterMarkdown,
|
||||
schema: Record<string, unknown>,
|
||||
): FrontmatterValidationError[]
|
||||
|
||||
type ParsedFrontmatterMarkdown = {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type AgentFrontmatter = { /* standard agent frontmatter fields */ };
|
||||
type FrontmatterScope = string;
|
||||
type FrontmatterStatus = string;
|
||||
type FrontmatterValidationError = { path: string; message: string };
|
||||
```
|
||||
|
||||
### Result helpers
|
||||
|
||||
```typescript
|
||||
function ok<T>(value: T): Result<T, never>
|
||||
function err<E>(error: E): Result<never, E>
|
||||
|
||||
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
|
||||
```
|
||||
|
||||
### Storage paths
|
||||
|
||||
```typescript
|
||||
function getDefaultWorkflowStorageRoot(): string
|
||||
function getGlobalCasDir(storageRoot: string | undefined): string
|
||||
```
|
||||
|
||||
### Refs and misc
|
||||
|
||||
```typescript
|
||||
function normalizeRefsField(value: unknown): string[]
|
||||
function generateCliReference(): string
|
||||
function env(name: string, fallback: string): string
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
parseFrontmatterMarkdown,
|
||||
} from "@united-workforce/util";
|
||||
|
||||
const log = createLogger();
|
||||
log("4KNMR2PX", "Loading workflow...");
|
||||
|
||||
const root = getDefaultWorkflowStorageRoot();
|
||||
const threadId = generateUlid(Date.now());
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── base32.ts Crockford Base32 encode/decode
|
||||
├── ulid.ts ULID generation
|
||||
├── logger.ts Structured logger
|
||||
├── process-logger/ Process-level debug log files
|
||||
├── frontmatter-markdown/ Parse and validate agent frontmatter
|
||||
├── refs-field.ts Normalize refs arrays on CAS nodes
|
||||
├── result.ts ok / err helpers
|
||||
├── storage-root.ts Default ~/.uncaged/workflow paths
|
||||
├── env.ts Environment variable helper
|
||||
├── cli-reference.ts Markdown CLI reference generator
|
||||
└── types.ts LogFn, Result, logger options
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`getDefaultWorkflowStorageRoot()` resolves to `~/.uncaged/workflow` unless overridden by environment (see `storage-root.ts`).
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { env } from "../src/env.js";
|
||||
|
||||
describe("env", () => {
|
||||
it("returns env value when set", () => {
|
||||
process.env.TEST_ENV_SET = "hello";
|
||||
expect(env("TEST_ENV_SET", "default")).toBe("hello");
|
||||
delete process.env.TEST_ENV_SET;
|
||||
});
|
||||
|
||||
it("returns fallback when missing", () => {
|
||||
expect(env("TEST_ENV_MISSING_XYZ", "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
it("returns fallback when empty", () => {
|
||||
process.env.TEST_ENV_EMPTY = "";
|
||||
expect(env("TEST_ENV_EMPTY", "fb")).toBe("fb");
|
||||
delete process.env.TEST_ENV_EMPTY;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { AgentFrontmatter } from "../src/index.js";
|
||||
import { parseFrontmatterMarkdown, validateFrontmatter } from "../src/index.js";
|
||||
|
||||
// ── parseFrontmatterMarkdown ─────────────────────────────────────────────────
|
||||
|
||||
describe("parseFrontmatterMarkdown", () => {
|
||||
describe("no frontmatter", () => {
|
||||
it("returns null frontmatter and full text as body when no fence", () => {
|
||||
const raw = "Just some markdown text.\n\n## Section\n\nContent.";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).toBeNull();
|
||||
expect(result.body).toBe(raw);
|
||||
});
|
||||
|
||||
it("returns null frontmatter when --- appears mid-document", () => {
|
||||
const raw = "# Heading\n\n---\n\nContent.";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).toBeNull();
|
||||
expect(result.body).toBe(raw);
|
||||
});
|
||||
|
||||
it("returns null frontmatter when opening fence is not followed by newline", () => {
|
||||
const raw = "--- inline content ---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).toBeNull();
|
||||
expect(result.body).toBe(raw);
|
||||
});
|
||||
|
||||
it("returns null frontmatter when no closing fence", () => {
|
||||
const raw = "---\nstatus: done\nbody without close";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).toBeNull();
|
||||
expect(result.body).toBe(raw);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
const result = parseFrontmatterMarkdown("");
|
||||
expect(result.frontmatter).toBeNull();
|
||||
expect(result.body).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status-only frontmatter", () => {
|
||||
it("parses status-only frontmatter", () => {
|
||||
const raw = "---\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).not.toBeNull();
|
||||
expect(result.frontmatter).toEqual({ status: "done" });
|
||||
expect(result.body).toBe("body");
|
||||
});
|
||||
|
||||
it("strips leading newline from body", () => {
|
||||
const raw = "---\nstatus: done\n---\n\nbody here";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.body).toBe("body here");
|
||||
});
|
||||
|
||||
it("body is empty string when nothing after closing fence", () => {
|
||||
const raw = "---\nstatus: done\n---\n";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.body).toBe("");
|
||||
});
|
||||
|
||||
it("body is empty string when document ends exactly at closing fence", () => {
|
||||
const raw = "---\nstatus: done\n---";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.body).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignores legacy fields", () => {
|
||||
it("legacy fields next/confidence/artifacts/scope are NOT present on result", () => {
|
||||
const raw =
|
||||
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts:\n - src/foo.ts\nscope: thread\n---\n\nBody.";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).not.toBeNull();
|
||||
const fm = result.frontmatter!;
|
||||
expect(fm.status).toBe("done");
|
||||
// Legacy fields must not exist on the object at all
|
||||
expect("next" in fm).toBe(false);
|
||||
expect("confidence" in fm).toBe(false);
|
||||
expect("artifacts" in fm).toBe(false);
|
||||
expect("scope" in fm).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status field", () => {
|
||||
it.each([
|
||||
"done",
|
||||
"needs_input",
|
||||
"in_progress",
|
||||
"failed",
|
||||
] as const)('parses status "%s"', (status) => {
|
||||
const raw = `---\nstatus: ${status}\n---\nbody`;
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.status).toBe(status);
|
||||
});
|
||||
|
||||
it("returns null status for unknown value", () => {
|
||||
const raw = "---\nstatus: unknown_value\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.status).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null status when omitted", () => {
|
||||
const raw = "---\nfoo: bar\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown fields", () => {
|
||||
it("ignores unknown keys silently", () => {
|
||||
const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.status).toBe("done");
|
||||
expect(Object.keys(result.frontmatter!)).toEqual(["status"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML comments", () => {
|
||||
it("ignores YAML comment lines", () => {
|
||||
const raw = "---\n# this is a comment\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.status).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty frontmatter block", () => {
|
||||
it("parses empty frontmatter with status null", () => {
|
||||
const raw = "---\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).not.toBeNull();
|
||||
const fm = result.frontmatter!;
|
||||
expect(fm.status).toBeNull();
|
||||
expect(Object.keys(fm)).toEqual(["status"]);
|
||||
expect(result.body).toBe("body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentFrontmatter has exactly one field", () => {
|
||||
it("has only status key", () => {
|
||||
const fm: AgentFrontmatter = { status: null };
|
||||
expect(Object.keys(fm)).toEqual(["status"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrontmatterValidationError only has status variant", () => {
|
||||
it("status variant is valid", () => {
|
||||
const err: import("../src/index.js").FrontmatterValidationError = {
|
||||
field: "status",
|
||||
message: "test",
|
||||
};
|
||||
expect(err.field).toBe("status");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateFrontmatter ──────────────────────────────────────────────────────
|
||||
|
||||
describe("validateFrontmatter", () => {
|
||||
it("returns no errors for a valid status", () => {
|
||||
const errors = validateFrontmatter({ status: "done" });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns no errors when status is null", () => {
|
||||
const errors = validateFrontmatter({ status: null });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns error for invalid status", () => {
|
||||
const errors = validateFrontmatter({ status: "bogus" as never });
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.field).toBe("status");
|
||||
});
|
||||
|
||||
it("no validation for next/confidence/artifacts/scope — fields do not exist", () => {
|
||||
// AgentFrontmatter only has status — verify at runtime
|
||||
const fm: AgentFrontmatter = { status: "done" };
|
||||
expect(Object.keys(fm)).toEqual(["status"]);
|
||||
expect(validateFrontmatter(fm)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createProcessLogger } from "../src/process-logger/index.js";
|
||||
|
||||
function logDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
describe("createProcessLogger", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir !== undefined) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writes init and log lines to dated JSONL under storage root", () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "uwf-process-log-"));
|
||||
const plog = createProcessLogger({
|
||||
storageRoot: tmpDir,
|
||||
context: { thread: "THREAD01", workflow: "WORKFLOW01" },
|
||||
});
|
||||
|
||||
expect(plog.pid).toMatch(/^\d+-\d+$/);
|
||||
|
||||
plog.log("7NQW4HBT", "moderator selected role=planner", null);
|
||||
|
||||
const logPath = join(tmpDir, "logs", `${logDateKey(new Date())}.jsonl`);
|
||||
const lines = readFileSync(logPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, string>);
|
||||
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]?.tag).toBe("W9F3RK2M");
|
||||
expect(lines[0]?.pid).toBe(plog.pid);
|
||||
expect(lines[0]?.thread).toBe("THREAD01");
|
||||
expect(lines[0]?.workflow).toBe("WORKFLOW01");
|
||||
expect(lines[0]?.msg).toContain("process start");
|
||||
expect(lines[0]?.msg).toContain("node=");
|
||||
|
||||
expect(lines[1]?.tag).toBe("7NQW4HBT");
|
||||
expect(lines[1]?.msg).toBe("moderator selected role=planner");
|
||||
expect(lines[1]?.thread).toBe("THREAD01");
|
||||
expect(lines[1]?.workflow).toBe("WORKFLOW01");
|
||||
});
|
||||
|
||||
test("creates logs directory when missing", () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "uwf-process-log-"));
|
||||
createProcessLogger({
|
||||
storageRoot: tmpDir,
|
||||
context: { thread: null, workflow: null },
|
||||
});
|
||||
mkdirSync(join(tmpDir, "logs"), { recursive: true });
|
||||
expect(() =>
|
||||
readFileSync(join(tmpDir, "logs", `${logDateKey(new Date())}.jsonl`), "utf8"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("merges per-call context into the JSONL entry", () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "uwf-process-log-"));
|
||||
const plog = createProcessLogger({
|
||||
storageRoot: tmpDir,
|
||||
context: { thread: "T1", workflow: null },
|
||||
});
|
||||
plog.log("M3K8V9T1", "spawn agent", { command: "uwf-hermes", args: "tid role" });
|
||||
|
||||
const logPath = join(tmpDir, "logs", `${logDateKey(new Date())}.jsonl`);
|
||||
const lines = readFileSync(logPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, string>);
|
||||
const last = lines[lines.length - 1];
|
||||
expect(last?.command).toBe("uwf-hermes");
|
||||
expect(last?.args).toBe("tid role");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@united-workforce/util",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "bun test __tests__/ src/__tests__/",
|
||||
"test:ci": "bun test __tests__/ src/__tests__/"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/util"
|
||||
},
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
|
||||
|
||||
describe("extractUlidTimestamp", () => {
|
||||
it("should extract correct timestamp from ULID", () => {
|
||||
const knownTimestamp = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||
const ulid = generateUlid(knownTimestamp);
|
||||
const extracted = extractUlidTimestamp(ulid);
|
||||
expect(extracted).toBe(knownTimestamp);
|
||||
});
|
||||
|
||||
it("should handle epoch timestamp (timestamp 0)", () => {
|
||||
const ulid = generateUlid(0);
|
||||
const extracted = extractUlidTimestamp(ulid);
|
||||
expect(extracted).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle recent timestamps", () => {
|
||||
const recentTimestamp = Date.now();
|
||||
const ulid = generateUlid(recentTimestamp);
|
||||
const extracted = extractUlidTimestamp(ulid);
|
||||
expect(extracted).toBe(recentTimestamp);
|
||||
});
|
||||
|
||||
it("should handle max 48-bit timestamp", () => {
|
||||
const maxTimestamp = 2 ** 48 - 1;
|
||||
const ulid = generateUlid(maxTimestamp);
|
||||
const extracted = extractUlidTimestamp(ulid);
|
||||
expect(extracted).toBe(maxTimestamp);
|
||||
});
|
||||
|
||||
it("should return null for invalid ULID length", () => {
|
||||
expect(extractUlidTimestamp("")).toBe(null);
|
||||
expect(extractUlidTimestamp("TOOSHORT")).toBe(null);
|
||||
expect(extractUlidTimestamp("TOOLONGAAAAAAAAAAAAAAAAAA")).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null for invalid Crockford Base32 characters", () => {
|
||||
expect(extractUlidTimestamp("INVALID!@#$%^&CHARACTERS")).toBe(null);
|
||||
});
|
||||
|
||||
it("should extract timestamps from multiple ULIDs correctly", () => {
|
||||
const timestamps = [
|
||||
Date.UTC(2020, 0, 1, 0, 0, 0),
|
||||
Date.UTC(2023, 5, 15, 12, 30, 45),
|
||||
Date.UTC(2026, 11, 31, 23, 59, 59),
|
||||
];
|
||||
|
||||
for (const ts of timestamps) {
|
||||
const ulid = generateUlid(ts);
|
||||
const extracted = extractUlidTimestamp(ulid);
|
||||
expect(extracted).toBe(ts);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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** via the \`ocas\` CLI to store intermediate artifacts, build merkle DAGs for large outputs, or reference data from previous steps.
|
||||
|
||||
### Commands
|
||||
|
||||
\`\`\`
|
||||
ocas put <type-hash> <json> # store typed JSON data, print hash
|
||||
ocas get <hash> # read a CAS node (type + payload)
|
||||
ocas has <hash> # check if a hash exists
|
||||
ocas refs <hash> # list direct references from a node
|
||||
ocas walk <hash> # recursive traversal from a node
|
||||
ocas schema list # list registered schemas
|
||||
ocas schema get <hash> # show a schema definition
|
||||
\`\`\`
|
||||
|
||||
Plain-text storage for agent output is handled internally by the uwf pipeline — agents do not need to call \`ocas put\` for their deliverables.
|
||||
|
||||
### Merkle DAG Pattern
|
||||
|
||||
For large outputs, store parts individually and reference their hashes:
|
||||
|
||||
\`\`\`bash
|
||||
# Store individual sections (use ocas put with the appropriate type hash)
|
||||
HASH1=$(ocas put <type-hash> '"section 1 content"')
|
||||
HASH2=$(ocas put <type-hash> '"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 "@united-workforce/util-agent";
|
||||
import type { AgentContext, AgentRunResult, AgentContinueFn, AgentRunFn } from "@united-workforce/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 \`@united-workforce/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 \`~/.uwf/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\` | \`@united-workforce/agent-hermes\` | Hermes ACP (chat sessions) |
|
||||
| \`uwf-builtin\` | \`@united-workforce/agent-builtin\` | Direct OpenAI API (tools + loop) |
|
||||
| \`uwf-claude-code\` | \`@united-workforce/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,60 @@
|
||||
export function generateArchitectureReference(): string {
|
||||
return `# Workflow Engine — Architecture Reference
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### CAS (Content-Addressed Storage)
|
||||
Every artifact in the workflow engine is stored as a CAS node — an immutable, content-addressed record identified by its XXH64 hash (13-char Crockford Base32). CAS provides deduplication, integrity verification, and an append-only audit trail.
|
||||
|
||||
Stored artifacts include:
|
||||
- **Workflow definitions** — the YAML-parsed payload
|
||||
- **Step nodes** — each moderator→agent→extract cycle
|
||||
- **Detail nodes** — per-step metadata and turn history
|
||||
- **Turn records** — individual agent interactions within a step
|
||||
|
||||
### Thread
|
||||
A Thread is a single execution of a Workflow, identified by a ULID (26-char Crockford Base32: 10 timestamp + 16 random). Thread state is an immutable CAS chain — each step points to its predecessor via a \`prev\` hash, forming a linked list.
|
||||
|
||||
Active threads are indexed in \`threads.yaml\`; completed threads move to \`history.jsonl\`.
|
||||
|
||||
A thread progresses by running \`uwf thread exec\`, which performs one moderator→agent→extract cycle per step.
|
||||
|
||||
### Workflow
|
||||
A Workflow is a YAML definition (\`WorkflowPayload\`) stored as a CAS node. It defines:
|
||||
- **Roles** — named actors with system prompts and output schemas
|
||||
- **Graph** — status-based routing edges between roles
|
||||
- **Conditions** — edge predicates evaluated by the moderator
|
||||
|
||||
Workflow names follow verb-first kebab-case: \`solve-issue\`, \`review-code\`.
|
||||
|
||||
### Step
|
||||
A Step is one moderator→agent→extract cycle, stored as a CAS node (\`StepNodePayload\`). Each step contains:
|
||||
- **output** — the agent's extracted frontmatter output
|
||||
- **detail** — a CAS reference to turn-level records
|
||||
- **prev** — CAS hash of the previous step (forming the chain)
|
||||
- **role** — which role produced this step
|
||||
|
||||
### Turn
|
||||
A Turn is an agent-internal interaction within a single Step. Turns are stored per-turn in the detail node, capturing the raw agent I/O before extraction.
|
||||
|
||||
## Data Flow
|
||||
|
||||
\`\`\`
|
||||
uwf thread exec <thread-id>
|
||||
→ Moderator evaluates graph edges based on current status
|
||||
→ Selects next role (or $END)
|
||||
→ Agent CLI is spawned with context
|
||||
→ Agent produces frontmatter markdown
|
||||
→ Extract pipeline parses output into structured data
|
||||
→ New CAS step node is appended to the thread chain
|
||||
\`\`\`
|
||||
|
||||
## Storage Layout
|
||||
|
||||
All data lives under \`~/.uwf/\`:
|
||||
- \`cas/\` — content-addressed store (XXH64-keyed)
|
||||
- \`threads.yaml\` — active thread index
|
||||
- \`history.jsonl\` — completed thread archive
|
||||
- \`registry.yaml\` — workflow name → CAS hash mapping
|
||||
`;
|
||||
}
|
||||
@@ -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
|
||||
ocas 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,81 @@
|
||||
import { err, ok } from "./result.js";
|
||||
import type { Result } from "./types.js";
|
||||
|
||||
/** Crockford Base32 alphabet (no I, L, O, U) — exactly 32 symbols. */
|
||||
export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
const DECODE_MAP: Record<string, number> = (() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (let i = 0; i < CROCKFORD_BASE32_ALPHABET.length; i++) {
|
||||
map[CROCKFORD_BASE32_ALPHABET[i]] = i;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
function padBitCount(bitLength: number): number {
|
||||
const r = bitLength % 5;
|
||||
return r === 0 ? 0 : 5 - r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an integer using exactly `bitLength` significant bits, MSB-first,
|
||||
* with the minimum number of leading zero bits so the total is a multiple of 5.
|
||||
*/
|
||||
export function encodeCrockfordBase32Bits(value: bigint, bitLength: number): string {
|
||||
if (bitLength <= 0) {
|
||||
throw new Error("bitLength must be positive");
|
||||
}
|
||||
const padBits = padBitCount(bitLength);
|
||||
const totalBits = bitLength + padBits;
|
||||
const charCount = totalBits / 5;
|
||||
const shifted = value << BigInt(padBits);
|
||||
let result = "";
|
||||
for (let i = 0; i < charCount; i++) {
|
||||
const shift = totalBits - 5 * (i + 1);
|
||||
const quintet = Number((shifted >> BigInt(shift)) & 31n);
|
||||
result += CROCKFORD_BASE32_ALPHABET[quintet];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function decodeCrockfordBase32Bits(
|
||||
encoded: string,
|
||||
bitLength: number,
|
||||
): Result<bigint, Error> {
|
||||
if (bitLength <= 0) {
|
||||
return err(new Error("bitLength must be positive"));
|
||||
}
|
||||
const padBits = padBitCount(bitLength);
|
||||
const totalBits = encoded.length * 5;
|
||||
if (totalBits !== bitLength + padBits) {
|
||||
return err(new Error("encoded length does not match bitLength"));
|
||||
}
|
||||
let shifted = 0n;
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
const ch = encoded[i];
|
||||
if (ch === undefined) {
|
||||
return err(new Error("invalid encoded string"));
|
||||
}
|
||||
const upper = ch.toUpperCase();
|
||||
const val = DECODE_MAP[upper];
|
||||
if (val === undefined) {
|
||||
return err(new Error(`invalid Crockford Base32 character: ${ch}`));
|
||||
}
|
||||
shifted = (shifted << 5n) | BigInt(val & 31);
|
||||
}
|
||||
return ok(shifted >> BigInt(padBits));
|
||||
}
|
||||
|
||||
/** XXH64-sized value (13 Crockford chars). */
|
||||
export function encodeUint64AsCrockford(value: bigint): string {
|
||||
const masked = value & 0xffff_ffff_ffff_ffffn;
|
||||
return encodeCrockfordBase32Bits(masked, 64);
|
||||
}
|
||||
|
||||
export function decodeCrockfordToUint64(encoded: string): Result<bigint, Error> {
|
||||
const decoded = decodeCrockfordBase32Bits(encoded, 64);
|
||||
if (!decoded.ok) {
|
||||
return decoded;
|
||||
}
|
||||
return ok(decoded.value & 0xffff_ffff_ffff_ffffn);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export function generateBootstrapReference(): string {
|
||||
return `---
|
||||
name: uwf
|
||||
description: "Uncaged Workflow (uwf) — YAML 状态机工作流引擎。任务涉及 workflow 时加载此 skill。"
|
||||
tags: [workflow, uwf, uncaged]
|
||||
triggers:
|
||||
- uwf
|
||||
- workflow
|
||||
- 工作流
|
||||
---
|
||||
|
||||
# uwf (Uncaged Workflow)
|
||||
|
||||
YAML 状态机工作流引擎。当用户提到「workflow」「工作流」时,指的是 **uwf workflow**(YAML 定义的状态机),不是 Hermes skill。用 \`uwf\` CLI 操作,不要混淆。
|
||||
|
||||
## 首次使用
|
||||
|
||||
运行以下命令获取完整用法:
|
||||
|
||||
\`\`\`bash
|
||||
uwf skill user # 用户使用手册(CLI 命令、thread 生命周期)
|
||||
uwf skill author # workflow 编写指南(role 定义、graph 路由、schema)
|
||||
\`\`\`
|
||||
|
||||
## 快速参考
|
||||
|
||||
\`\`\`bash
|
||||
uwf workflow list # 查看已注册 workflow
|
||||
uwf workflow add <file.yaml> # 注册 workflow
|
||||
uwf thread start <workflow> -p "prompt" # 创建 thread
|
||||
uwf thread exec <thread-id> -c 10 # 执行最多 10 步
|
||||
uwf thread list # 查看所有 thread
|
||||
\`\`\`
|
||||
|
||||
## 示例 workflow
|
||||
|
||||
参考项目 \`examples/\` 目录下的 YAML 文件(analyze-topic、debate、solve-issue)。
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// MAINTENANCE: This string must be kept in sync with the actual uwf CLI commands.
|
||||
// Update whenever commands are added, removed, or their signatures change.
|
||||
export function generateCliReference(): string {
|
||||
return `# uwf CLI Reference
|
||||
|
||||
## Setup
|
||||
|
||||
\`\`\`
|
||||
uwf setup # interactive setup wizard
|
||||
uwf setup --provider <name> --base-url <url> \\
|
||||
--api-key <key> --model <name> # non-interactive setup
|
||||
[--agent <name>] # optional: default agent alias
|
||||
\`\`\`
|
||||
|
||||
## Workflow Commands
|
||||
|
||||
\`\`\`
|
||||
uwf workflow add <file> # register a workflow from YAML file
|
||||
uwf workflow show <id> # show workflow by name or CAS hash
|
||||
uwf workflow list # list all registered workflows
|
||||
\`\`\`
|
||||
|
||||
## Thread Commands
|
||||
|
||||
\`\`\`
|
||||
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
|
||||
uwf thread exec <thread-id> # execute one moderator→agent→extract cycle
|
||||
[--agent <cmd>] # override agent command
|
||||
[-c, --count <number>] # run multiple steps (default: 1)
|
||||
[--background] # run in background
|
||||
uwf thread show <thread-id> # show thread head pointer
|
||||
uwf thread list # list threads
|
||||
[--status <status>] # filter: idle, running, or completed
|
||||
uwf thread read <thread-id> # render thread context as markdown
|
||||
[--quota <chars>] # max output characters (default 32000)
|
||||
[--before <step-hash>] # load steps before this hash (exclusive)
|
||||
[--start] # include start step in output
|
||||
uwf thread stop <thread-id> # stop background execution (keep thread active)
|
||||
uwf thread cancel <thread-id> # cancel thread (stop + move to history)
|
||||
\`\`\`
|
||||
|
||||
## Step Commands
|
||||
|
||||
\`\`\`
|
||||
uwf step list <thread-id> # list all steps in a thread
|
||||
uwf step show <step-hash> # show details of a specific step
|
||||
uwf step fork <step-hash> # fork a thread from a specific step
|
||||
\`\`\`
|
||||
|
||||
## CAS Commands
|
||||
|
||||
Use the \`ocas\` CLI for direct CAS operations (\`~/.ocas/\` store, shared with \`uwf\`):
|
||||
|
||||
\`\`\`
|
||||
ocas get <hash> # read a CAS node (type + payload)
|
||||
[--timestamp] # include timestamp in output
|
||||
ocas put <type-hash> <data> # store a node, print its hash
|
||||
# <data>: JSON file path or inline JSON string
|
||||
ocas has <hash> # check if a hash exists
|
||||
ocas refs <hash> # list direct CAS references from a node
|
||||
ocas walk <hash> # recursive traversal from a node
|
||||
ocas reindex # rebuild type index from all CAS nodes
|
||||
ocas schema list # list all registered schemas
|
||||
ocas schema get <hash> # show a schema by its type hash
|
||||
\`\`\`
|
||||
|
||||
## Log Commands
|
||||
|
||||
\`\`\`
|
||||
uwf log list # list log files with sizes
|
||||
uwf log show # show all log entries
|
||||
[--thread <thread-id>] # filter by thread ID
|
||||
[--process <pid>] # filter by process ID
|
||||
[--date <YYYY-MM-DD>] # filter by date
|
||||
uwf log clean --before <date> # delete log files before given date
|
||||
\`\`\`
|
||||
|
||||
## Global Options
|
||||
|
||||
\`\`\`
|
||||
uwf --format <fmt> # output format: json (default) or yaml
|
||||
uwf -V, --version # print version
|
||||
\`\`\`
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
|
||||
- **Thread**: A running instance of a workflow; points to a chain of CAS step nodes.
|
||||
- **Step**: One moderator→agent→extract cycle; stored as a CAS node with output + detail refs.
|
||||
- **Turn**: Agent-internal interaction (within a single step); stored per-turn in the detail node.
|
||||
- **CAS**: Content-addressable store; every artifact (workflows, steps, details, turns) is hashed.
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export function generateDeveloperReference(): string {
|
||||
return `# Developer Reference
|
||||
|
||||
Guide for contributing to the workflow engine codebase.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
\`\`\`
|
||||
packages/
|
||||
protocol/ # Shared types (WorkflowPayload, StepNodePayload, etc.)
|
||||
util/ # Base32, ULID, logger, frontmatter parsing, skill references
|
||||
util-agent/ # createAgent factory, context builder, extract pipeline
|
||||
agent-hermes/ # uwf-hermes CLI (spawns Hermes chat sessions)
|
||||
agent-builtin/ # uwf-builtin CLI (direct LLM calls via OpenAI API)
|
||||
cli/ # 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
|
||||
\`\`\`
|
||||
|
||||
External CAS: \`@ocas/core\` (store API, hashing, schema validation) + \`@ocas/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 \`@united-workforce/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 "@united-workforce/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 (\`@united-workforce/cli\`) 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\`: vitest
|
||||
- Other packages: \`bun test\`
|
||||
- Test files live in \`__tests__/\` directories
|
||||
|
||||
### Publishing
|
||||
|
||||
Fixed-mode versioning — all \`@united-workforce/*\` 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/src/moderator/\`)
|
||||
|
||||
Status-based graph evaluator. Reads \`graph[lastRole][output.$status]\` to determine the next role. Zero LLM cost.
|
||||
|
||||
### Extract Pipeline (\`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 (\`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 \`@ocas/core\`:
|
||||
- \`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
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Read an environment variable with a required fallback default.
|
||||
* Returns the env value if set and non-empty, otherwise returns `fallback`.
|
||||
*
|
||||
* Every env var in a bundle must have a sensible default — bundles must run
|
||||
* without any env vars set. Env vars are overrides, not requirements.
|
||||
*/
|
||||
export function env(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
AgentFrontmatter,
|
||||
FrontmatterStatus,
|
||||
FrontmatterValidationError,
|
||||
ParsedFrontmatterMarkdown,
|
||||
} from "./types.js";
|
||||
|
||||
// ── YAML frontmatter extractor ───────────────────────────────────────────────
|
||||
|
||||
const FENCE = "---";
|
||||
|
||||
/**
|
||||
* Split a raw agent response into a YAML string (or null) and a markdown body.
|
||||
*
|
||||
* A frontmatter block MUST:
|
||||
* 1. Start at character position 0 with `---` (no leading whitespace / BOM).
|
||||
* 2. Be closed by a second `---` on its own line.
|
||||
*
|
||||
* Anything that doesn't match this shape is returned verbatim as the body.
|
||||
*/
|
||||
function splitFrontmatter(raw: string): { yaml: string | null; body: string } {
|
||||
if (!raw.startsWith(FENCE)) {
|
||||
return { yaml: null, body: raw };
|
||||
}
|
||||
|
||||
const rest = raw.slice(FENCE.length);
|
||||
// The opening `---` must be followed immediately by a newline (or end-of-string).
|
||||
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
|
||||
return { yaml: null, body: raw };
|
||||
}
|
||||
// Consume the newline after the opening fence so that `afterOpen` starts at the
|
||||
// first line of YAML content (not a leading empty line).
|
||||
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
|
||||
|
||||
const closeIndex = afterOpen.indexOf(`\n${FENCE}`);
|
||||
if (closeIndex === -1) {
|
||||
// Also handle the edge case where frontmatter is empty: `---\n---`
|
||||
if (afterOpen.startsWith(FENCE)) {
|
||||
const afterClose = afterOpen.slice(FENCE.length);
|
||||
const body = afterClose.replace(/^\n+/, "");
|
||||
return { yaml: "", body };
|
||||
}
|
||||
return { yaml: null, body: raw };
|
||||
}
|
||||
|
||||
const yaml = afterOpen.slice(0, closeIndex);
|
||||
// Skip past `\n---` and strip any leading blank separator lines from the body.
|
||||
const afterClose = afterOpen.slice(closeIndex + 1 + FENCE.length);
|
||||
const body = afterClose.replace(/^\n+/, "");
|
||||
|
||||
return { yaml, body };
|
||||
}
|
||||
|
||||
// ── Minimal YAML scalar parser ───────────────────────────────────────────────
|
||||
//
|
||||
// We intentionally avoid a full YAML library dependency inside util.
|
||||
// The frontmatter schema is flat and uses only scalars + simple string lists.
|
||||
// This parser handles exactly what the spec needs and nothing more.
|
||||
|
||||
type YamlValue = string | number | boolean | null | string[];
|
||||
|
||||
function parseYamlScalar(raw: string): YamlValue {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Quoted string
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "true") return true;
|
||||
if (lower === "false") return false;
|
||||
if (lower === "null" || lower === "~" || lower === "") return null;
|
||||
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isNaN(num) && trimmed !== "") return num;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function collectBlockSequence(
|
||||
lines: string[],
|
||||
startIdx: number,
|
||||
): { items: string[]; nextIdx: number } {
|
||||
const items: string[] = [];
|
||||
let i = startIdx;
|
||||
while (i < lines.length) {
|
||||
const itemTrimmed = (lines[i] ?? "").trimStart();
|
||||
if (!itemTrimmed.startsWith("- ")) break;
|
||||
items.push(itemTrimmed.slice(2).trim());
|
||||
i++;
|
||||
}
|
||||
return { items, nextIdx: i };
|
||||
}
|
||||
|
||||
function parseInlineSequence(restTrimmed: string): string[] {
|
||||
const inner = restTrimmed.slice(1, -1);
|
||||
return inner
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== "");
|
||||
}
|
||||
|
||||
function parseKeyValue(
|
||||
lines: string[],
|
||||
i: number,
|
||||
): { key: string; value: YamlValue; nextIdx: number } | null {
|
||||
const line = lines[i] ?? "";
|
||||
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
const colonIdx = line.indexOf(":");
|
||||
if (colonIdx === -1) {
|
||||
return null;
|
||||
}
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
const restTrimmed = line.slice(colonIdx + 1).trim();
|
||||
|
||||
if (restTrimmed === "") {
|
||||
const { items, nextIdx } = collectBlockSequence(lines, i + 1);
|
||||
return { key, value: items, nextIdx };
|
||||
}
|
||||
if (restTrimmed.startsWith("[") && restTrimmed.endsWith("]")) {
|
||||
return { key, value: parseInlineSequence(restTrimmed), nextIdx: i + 1 };
|
||||
}
|
||||
return { key, value: parseYamlScalar(restTrimmed), nextIdx: i + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a minimal flat YAML document. Only supports:
|
||||
* - Scalar key: value pairs
|
||||
* - Block sequences under a key (items prefixed with ` - `)
|
||||
*
|
||||
* Returns a plain object. Never throws — unparseable lines are silently skipped.
|
||||
*/
|
||||
function parseMinimalYaml(yaml: string): Record<string, YamlValue> {
|
||||
const result: Record<string, YamlValue> = {};
|
||||
const lines = yaml.split("\n");
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const entry = parseKeyValue(lines, i);
|
||||
if (entry === null) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
result[entry.key] = entry.value;
|
||||
i = entry.nextIdx;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Field coercers ───────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"];
|
||||
|
||||
function coerceStatus(raw: YamlValue): FrontmatterStatus | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const s = String(raw).trim().toLowerCase();
|
||||
return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a raw agent response string into structured frontmatter + body.
|
||||
*
|
||||
* - Never throws: malformed YAML is silently treated as "no frontmatter".
|
||||
* - The returned `frontmatter` is `null` when no valid `---…---` block was found.
|
||||
* - Unknown YAML keys are silently ignored.
|
||||
* - Invalid scalar values for known keys are coerced to their null/default.
|
||||
*/
|
||||
export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown {
|
||||
const { yaml, body } = splitFrontmatter(raw);
|
||||
|
||||
if (yaml === null) {
|
||||
return { frontmatter: null, body };
|
||||
}
|
||||
|
||||
let fields: Record<string, YamlValue>;
|
||||
try {
|
||||
fields = parseMinimalYaml(yaml);
|
||||
} catch {
|
||||
// Unparseable YAML → treat as no frontmatter; keep full raw as body.
|
||||
return { frontmatter: null, body: raw };
|
||||
}
|
||||
|
||||
const frontmatter: AgentFrontmatter = {
|
||||
status: coerceStatus(fields.status ?? null),
|
||||
};
|
||||
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed `AgentFrontmatter` and return a list of violations.
|
||||
*
|
||||
* An empty array means the frontmatter is valid.
|
||||
*
|
||||
* Validated constraints:
|
||||
* - `status` — must be one of the FrontmatterStatus literals (if non-null)
|
||||
*/
|
||||
export function validateFrontmatter(
|
||||
frontmatter: AgentFrontmatter,
|
||||
): readonly FrontmatterValidationError[] {
|
||||
const errors: FrontmatterValidationError[] = [];
|
||||
|
||||
if (frontmatter.status !== null && !VALID_STATUS.includes(frontmatter.status)) {
|
||||
errors.push({
|
||||
field: "status",
|
||||
message: `invalid status "${frontmatter.status}"; must be one of: ${VALID_STATUS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { parseFrontmatterMarkdown, validateFrontmatter } from "./frontmatter-markdown.js";
|
||||
export type {
|
||||
AgentFrontmatter,
|
||||
FrontmatterStatus,
|
||||
FrontmatterValidationError,
|
||||
ParsedFrontmatterMarkdown,
|
||||
} from "./types.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Frontmatter Markdown — agent output format.
|
||||
*
|
||||
* An agent response is a Markdown document with an optional YAML frontmatter
|
||||
* block at the top. The frontmatter carries structured signals that the
|
||||
* moderator and engine can consume without running a full LLM extract pass.
|
||||
*
|
||||
* Wire format:
|
||||
*
|
||||
* ---
|
||||
* status: done
|
||||
* ---
|
||||
*
|
||||
* ... free-form markdown body ...
|
||||
*
|
||||
* Only `status` is a standard frontmatter field. All other fields are
|
||||
* role-specific and defined by the output schema.
|
||||
*/
|
||||
|
||||
// ── Vocabulary types ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* High-level signal from the agent about where work stands.
|
||||
*
|
||||
* - `done` — role completed its objective; moderator may advance
|
||||
* - `needs_input` — agent is blocked and requires human or peer clarification
|
||||
* - `in_progress` — work is underway but the agent chose to yield early
|
||||
* - `failed` — agent cannot complete the task and explains why in the body
|
||||
*/
|
||||
export type FrontmatterStatus = "done" | "needs_input" | "in_progress" | "failed";
|
||||
|
||||
// ── Core frontmatter schema ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed and validated frontmatter from an agent response.
|
||||
*
|
||||
* Only `status` is a standard field. All other fields are role-specific.
|
||||
*/
|
||||
export type AgentFrontmatter = {
|
||||
/**
|
||||
* Completion status signal from the agent.
|
||||
* Null when omitted — engine treats it as "done" for backward compatibility.
|
||||
*/
|
||||
status: FrontmatterStatus | null;
|
||||
};
|
||||
|
||||
// ── Parse output ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of `parseFrontmatterMarkdown`: the structured frontmatter (if present)
|
||||
* and the body (everything after the closing `---` fence, or the whole input
|
||||
* if no frontmatter was found).
|
||||
*/
|
||||
export type ParsedFrontmatterMarkdown = {
|
||||
/**
|
||||
* Parsed frontmatter fields. Null when no frontmatter block was detected
|
||||
* (i.e. the document does not start with `---`).
|
||||
*/
|
||||
frontmatter: AgentFrontmatter | null;
|
||||
|
||||
/** Markdown body with frontmatter block stripped. Leading newline removed. */
|
||||
body: string;
|
||||
};
|
||||
|
||||
// ── Validation error ─────────────────────────────────────────────────────────
|
||||
|
||||
export type FrontmatterValidationError = { field: "status"; message: string };
|
||||
@@ -0,0 +1,39 @@
|
||||
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 { generateBootstrapReference } from "./bootstrap-reference.js";
|
||||
export { generateCliReference } from "./cli-reference.js";
|
||||
export { generateDeveloperReference } from "./developer-reference.js";
|
||||
export { env } from "./env.js";
|
||||
export type {
|
||||
AgentFrontmatter,
|
||||
FrontmatterStatus,
|
||||
FrontmatterValidationError,
|
||||
ParsedFrontmatterMarkdown,
|
||||
} from "./frontmatter-markdown/index.js";
|
||||
export {
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "./frontmatter-markdown/index.js";
|
||||
export { createLogger } from "./logger.js";
|
||||
export { generateModeratorReference } from "./moderator-reference.js";
|
||||
export type {
|
||||
CreateProcessLoggerOptions,
|
||||
ProcessLogFn,
|
||||
ProcessLogger,
|
||||
ProcessLoggerContext,
|
||||
} from "./process-logger/index.js";
|
||||
export { createProcessLogger } from "./process-logger/index.js";
|
||||
export { normalizeRefsField } from "./refs-field.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export {
|
||||
getDefaultStorageRoot,
|
||||
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,30 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
import { assertValidLogTag } from "./process-logger/log-tag.js";
|
||||
import type { CreateLoggerOptions, LogFn } from "./types.js";
|
||||
|
||||
/** Append one JSONL log record: `{ tag, content, timestamp }` per RFC-001. */
|
||||
export function createLogger(options: CreateLoggerOptions): LogFn {
|
||||
if (options.sink.kind === "stderr") {
|
||||
return (tag: string, content: string) => {
|
||||
assertValidLogTag(tag);
|
||||
const line = `${JSON.stringify({
|
||||
tag: tag.toUpperCase(),
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
})}\n`;
|
||||
process.stderr.write(line);
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = options.sink.path;
|
||||
return (tag: string, content: string) => {
|
||||
assertValidLogTag(tag);
|
||||
const line = `${JSON.stringify({
|
||||
tag: tag.toUpperCase(),
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
})}\n`;
|
||||
appendFileSync(filePath, line, "utf8");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export function generateModeratorReference(): string {
|
||||
return `# Moderator Reference
|
||||
|
||||
## Overview
|
||||
|
||||
The moderator is the workflow engine's routing component. It evaluates the directed graph defined in the workflow YAML to determine the next role (or \`$END\`) after each step — with zero LLM cost.
|
||||
|
||||
## Status-Based Routing
|
||||
|
||||
The moderator uses **status-based routing**: it inspects the previous step's extracted output (specifically the \`$status\` field) and looks up the corresponding edge in the graph.
|
||||
|
||||
### Graph Structure
|
||||
|
||||
The graph is a nested map: \`Record<Role | "$START", Record<Status, Target>>\`. Each role maps its possible \`$status\` values to a target with a \`role\` and \`prompt\`:
|
||||
|
||||
\`\`\`yaml
|
||||
graph:
|
||||
$START:
|
||||
_: { role: planner, prompt: "Analyze the issue." }
|
||||
planner:
|
||||
ready: { role: developer, prompt: "Implement the plan (CAS hash: {{{plan}}})." }
|
||||
insufficient_info: { role: $END, prompt: "Not enough info." }
|
||||
developer:
|
||||
done: { role: reviewer, prompt: "Review branch {{{branch}}} at {{{worktree}}}." }
|
||||
failed: { role: $END, prompt: "Developer failed: {{{reason}}}." }
|
||||
reviewer:
|
||||
approved: { role: tester, prompt: "Run tests on {{{branch}}} at {{{worktree}}}." }
|
||||
rejected: { role: developer, prompt: "Fix issues: {{{comments}}}." }
|
||||
\`\`\`
|
||||
|
||||
### Routing Algorithm
|
||||
|
||||
1. Look up \`graph[lastRole]\` to get the status map for the current role
|
||||
2. Look up \`statusMap[lastOutput.$status]\` to get the target
|
||||
3. If target role is \`$END\`, mark thread as completed
|
||||
4. Otherwise, render the edge prompt (Mustache templates with \`{{{field}}}\` from output) and spawn the next agent
|
||||
|
||||
### Edge Prompts and Mustache Templates
|
||||
|
||||
Edge prompts use triple-brace Mustache syntax (\`{{{field}}}\`) to interpolate values from the previous step's output into the next agent's task prompt. This passes structured data (branch names, file paths, CAS hashes) between roles without manual wiring.
|
||||
|
||||
## Special Nodes
|
||||
|
||||
- \`$START\` — entry point; uses status key \`_\` (unconditional) since there is no previous output
|
||||
- \`$END\` — terminal node; thread completes when reached and is moved to history
|
||||
|
||||
## Integration with Steps
|
||||
|
||||
Each \`uwf thread exec\` cycle:
|
||||
1. Moderator reads the thread's head step output
|
||||
2. Looks up \`graph[lastRole][output.$status]\` to pick the next role
|
||||
3. If next is \`$END\`, marks thread as completed
|
||||
4. Otherwise, renders the edge prompt and spawns the agent for the selected role
|
||||
5. Extract pipeline parses agent output → new step node → append to CAS chain
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createProcessLogger } from "./process-logger.js";
|
||||
export type {
|
||||
CreateProcessLoggerOptions,
|
||||
ProcessLogFn,
|
||||
ProcessLogger,
|
||||
ProcessLoggerContext,
|
||||
} from "./types.js";
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CROCKFORD_BASE32_ALPHABET } from "../base32.js";
|
||||
|
||||
const TAG_LENGTH = 8;
|
||||
|
||||
const TAG_CHAR_SET: ReadonlySet<string> = new Set(CROCKFORD_BASE32_ALPHABET.split(""));
|
||||
|
||||
export function assertValidLogTag(tag: string): void {
|
||||
if (tag.length !== TAG_LENGTH) {
|
||||
throw new Error(`log tag must be exactly ${TAG_LENGTH} characters`);
|
||||
}
|
||||
for (let i = 0; i < tag.length; i++) {
|
||||
const ch = tag[i];
|
||||
if (ch === undefined) {
|
||||
throw new Error("log tag validation failed");
|
||||
}
|
||||
const upper = ch.toUpperCase();
|
||||
if (!TAG_CHAR_SET.has(upper)) {
|
||||
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getDefaultStorageRoot } from "../storage-root.js";
|
||||
import { assertValidLogTag } from "./log-tag.js";
|
||||
import type { CreateProcessLoggerOptions, ProcessLogger, ProcessLoggerContext } from "./types.js";
|
||||
|
||||
const INIT_TAG = "W9F3RK2M";
|
||||
|
||||
function logDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getProcessLogsDir(storageRoot: string): string {
|
||||
return join(storageRoot, "logs");
|
||||
}
|
||||
|
||||
function getProcessLogFilePath(storageRoot: string, date: Date): string {
|
||||
return join(getProcessLogsDir(storageRoot), `${logDateKey(date)}.jsonl`);
|
||||
}
|
||||
|
||||
function buildEntry(
|
||||
processId: string,
|
||||
tag: string,
|
||||
msg: string,
|
||||
baseContext: ProcessLoggerContext,
|
||||
extra: Record<string, string> | null,
|
||||
): Record<string, string> {
|
||||
const entry: Record<string, string> = {
|
||||
ts: new Date().toISOString(),
|
||||
pid: processId,
|
||||
tag: tag.toUpperCase(),
|
||||
msg,
|
||||
};
|
||||
if (baseContext.thread !== null) {
|
||||
entry.thread = baseContext.thread;
|
||||
}
|
||||
if (baseContext.workflow !== null) {
|
||||
entry.workflow = baseContext.workflow;
|
||||
}
|
||||
if (extra !== null) {
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
entry[key] = value;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function appendEntry(filePath: string, entry: Record<string, string>): void {
|
||||
appendFileSync(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/** Process-scoped debug logger — append-only JSONL under `<storageRoot>/logs/YYYY-MM-DD.jsonl`. */
|
||||
export function createProcessLogger(options: CreateProcessLoggerOptions): ProcessLogger {
|
||||
const storageRoot = options.storageRoot ?? getDefaultStorageRoot();
|
||||
const processId = `${Date.now()}-${process.pid}`;
|
||||
const baseContext = options.context;
|
||||
const logFilePath = getProcessLogFilePath(storageRoot, new Date());
|
||||
|
||||
mkdirSync(getProcessLogsDir(storageRoot), { recursive: true });
|
||||
|
||||
const log: ProcessLogger["log"] = (tag, msg, context = null) => {
|
||||
assertValidLogTag(tag);
|
||||
appendEntry(logFilePath, buildEntry(processId, tag, msg, baseContext, context));
|
||||
};
|
||||
|
||||
const argvSummary = JSON.stringify(process.argv);
|
||||
const initParts = [`argv=${argvSummary}`, `node=${process.version}`];
|
||||
if (baseContext.thread !== null) {
|
||||
initParts.push(`thread=${baseContext.thread}`);
|
||||
}
|
||||
if (baseContext.workflow !== null) {
|
||||
initParts.push(`workflow=${baseContext.workflow}`);
|
||||
}
|
||||
log(INIT_TAG, `process start ${initParts.join(" ")}`, null);
|
||||
|
||||
return { pid: processId, log };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type ProcessLoggerContext = {
|
||||
thread: string | null;
|
||||
workflow: string | null;
|
||||
};
|
||||
|
||||
export type CreateProcessLoggerOptions = {
|
||||
storageRoot: string | null;
|
||||
context: ProcessLoggerContext;
|
||||
};
|
||||
|
||||
export type ProcessLogFn = (
|
||||
tag: string,
|
||||
msg: string,
|
||||
context: Record<string, string> | null,
|
||||
) => void;
|
||||
|
||||
export type ProcessLogger = {
|
||||
pid: string;
|
||||
log: ProcessLogFn;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
|
||||
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
|
||||
const out = [...refs];
|
||||
if (!out.includes(contentHash)) {
|
||||
out.push(contentHash);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Normalize `refs` from persisted JSONL or IPC payloads (missing or invalid → []). */
|
||||
export function normalizeRefsField(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const x of value) {
|
||||
if (typeof x === "string") {
|
||||
out.push(x);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Result } from "./types.js";
|
||||
|
||||
export function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Default filesystem root for workflow data (`~/.uwf`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uwf");
|
||||
}
|
||||
|
||||
/** @deprecated Use `getDefaultStorageRoot` instead. */
|
||||
export function getDefaultWorkflowStorageRoot(): string {
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
|
||||
/** Global content-addressed store directory under the workflow storage root (`<root>/cas`). */
|
||||
export function getGlobalCasDir(storageRoot: string | undefined): string {
|
||||
const root = storageRoot ?? getDefaultStorageRoot();
|
||||
return join(root, "cas");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
|
||||
|
||||
export type CreateLoggerOptions = {
|
||||
sink: LoggerSink;
|
||||
};
|
||||
|
||||
export type LogFn = (tag: string, content: string) => void;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { decodeCrockfordBase32Bits, encodeCrockfordBase32Bits } from "./base32.js";
|
||||
|
||||
const ULID_TIME_BITS = 48;
|
||||
const ULID_RANDOM_BITS = 80;
|
||||
|
||||
function readRandomUint80(): bigint {
|
||||
const bytes = new Uint8Array(10);
|
||||
crypto.getRandomValues(bytes);
|
||||
let x = 0n;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
x = (x << 8n) | BigInt(bytes[i]);
|
||||
}
|
||||
return x & ((1n << 80n) - 1n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a ULID using Crockford Base32: 10 timestamp chars + 16 random chars.
|
||||
* Timestamp uses 48 bits of Unix time in milliseconds.
|
||||
*/
|
||||
export function generateUlid(nowMs: number): string {
|
||||
if (!Number.isFinite(nowMs) || nowMs < 0 || nowMs >= 2 ** ULID_TIME_BITS) {
|
||||
throw new Error("nowMs must be a finite number in [0, 2^48)");
|
||||
}
|
||||
const time = BigInt(Math.floor(nowMs));
|
||||
const rand = readRandomUint80();
|
||||
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
||||
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the timestamp (in milliseconds) from a ULID string.
|
||||
* Returns null if the ULID is invalid.
|
||||
*/
|
||||
export function extractUlidTimestamp(ulid: string): number | null {
|
||||
if (ulid.length !== 26) {
|
||||
return null;
|
||||
}
|
||||
const timestampPart = ulid.slice(0, 10);
|
||||
const decoded = decodeCrockfordBase32Bits(timestampPart, ULID_TIME_BITS);
|
||||
if (!decoded.ok) {
|
||||
return null;
|
||||
}
|
||||
return Number(decoded.value);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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 \`~/.uwf/config.yaml\`. Override storage root with \`UWF_STORAGE_ROOT\` (or \`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
|
||||
|
||||
Use the \`ocas\` CLI for direct CAS operations (\`~/.ocas/\` store, shared with \`uwf\`):
|
||||
|
||||
\`\`\`
|
||||
ocas get <hash> # read a node (type + payload)
|
||||
[--timestamp] # include timestamp
|
||||
ocas put <type-hash> <data> # store typed JSON, print hash
|
||||
ocas has <hash> # check existence
|
||||
ocas refs <hash> # list direct references
|
||||
ocas walk <hash> # recursive traversal
|
||||
ocas reindex # rebuild type index
|
||||
ocas schema list # list schemas
|
||||
ocas 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
|
||||
\`\`\`
|
||||
|
||||
## Other Skill References
|
||||
|
||||
For specific scenarios, run the corresponding \`uwf skill\` command:
|
||||
|
||||
| Scenario | Command | When to use |
|
||||
|----------|---------|-------------|
|
||||
| Writing workflow YAML | \`uwf skill author\` | Designing roles, conditions, graphs, and edge prompts |
|
||||
| Contributing to the engine | \`uwf skill developer\` | Modifying the workflow engine codebase itself |
|
||||
| Building a new agent adapter | \`uwf skill adapter\` | Creating a new \`uwf-<name>\` CLI adapter |
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export function generateYamlReference(): string {
|
||||
return `# Workflow YAML Schema Reference
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
A workflow YAML file defines the complete workflow specification:
|
||||
|
||||
\`\`\`yaml
|
||||
name: solve-issue # verb-first kebab-case identifier
|
||||
description: "..." # human-readable description
|
||||
|
||||
roles: # named actors in the workflow
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a plan"
|
||||
goal: "You are a planning agent."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
1. Read the issue
|
||||
2. Produce a test spec
|
||||
output: "Output the plan summary. Set $status to ready or insufficient_info."
|
||||
frontmatter: # JSON Schema for structured output (drives routing)
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: ready }
|
||||
plan: { type: string }
|
||||
required: [$status, plan]
|
||||
- properties:
|
||||
$status: { const: insufficient_info }
|
||||
required: [$status]
|
||||
|
||||
graph: # status-based routing (nested map)
|
||||
$START:
|
||||
_: { role: planner, prompt: "Analyze the issue." }
|
||||
planner:
|
||||
ready: { role: developer, prompt: "Implement plan {{{plan}}}." }
|
||||
insufficient_info: { role: $END, prompt: "Not enough info." }
|
||||
\`\`\`
|
||||
|
||||
## roles
|
||||
|
||||
Each role defines an actor in the workflow:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| \`description\` | string | Short description of the role's purpose |
|
||||
| \`goal\` | string | System-level goal statement for the agent |
|
||||
| \`capabilities\` | string[] | Tags describing what the role can do |
|
||||
| \`procedure\` | string | Step-by-step instructions for the agent |
|
||||
| \`output\` | string | Description of expected output format |
|
||||
| \`frontmatter\` | JSON Schema | Defines the structured output the agent must produce |
|
||||
|
||||
### frontmatter
|
||||
|
||||
The \`frontmatter\` field is a standard JSON Schema object. The extract pipeline validates agent output against it. Key conventions:
|
||||
- \`$status\` field drives routing decisions in the graph
|
||||
- Use \`const\` or \`enum\` to constrain status values
|
||||
- Use \`oneOf\` to define multiple valid output shapes (one per status)
|
||||
- All \`required\` fields must appear in the agent's frontmatter output
|
||||
|
||||
## graph
|
||||
|
||||
The graph is a nested map defining status-based routing:
|
||||
|
||||
\`\`\`
|
||||
Record<Role | "$START", Record<Status, { role: string, prompt: string }>>
|
||||
\`\`\`
|
||||
|
||||
| Level | Key | Value |
|
||||
|-------|-----|-------|
|
||||
| Outer | Role name or \`$START\` | Status map for that role |
|
||||
| Inner | \`$status\` value (or \`_\` for unconditional) | Target: \`{ role, prompt }\` |
|
||||
|
||||
### Special Nodes
|
||||
- \`$START\` — entry point; uses status key \`_\` (unconditional, no previous output)
|
||||
- \`$END\` — terminal node; thread completes when reached
|
||||
|
||||
### Edge Prompts
|
||||
Prompts use triple-brace Mustache templates (\`{{{field}}}\`) to interpolate values from the previous step's output. Example: \`"Implement plan {{{plan}}} in repo {{{repoPath}}}."\`
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
Reference in New Issue
Block a user