Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju c0e0e561f3 fix: setup UX improvements — adapter check, ENOENT, SQLite warning, VERSION, PATH docs
CI / check (pull_request) Failing after 2m4s
- setup validates adapter binary availability, prints install command if missing
- setup prints 'Config saved to <path> ✓' on success
- spawn ENOENT gives actionable error with which command
- SQLite ExperimentalWarning suppressed via NODE_OPTIONS
- bootstrap VERSION reads cli package.json (was reading util)
- bootstrap PATH guidance is shell-agnostic

Fixes #114
2026-06-05 15:29:41 +00:00
40 changed files with 271 additions and 523 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@united-workforce/cli": patch
---
fix: expand bootstrap prompt with full onboarding and upgrade guide
Bootstrap now covers two scenarios:
- Fresh install: CLI + adapter installation, `uwf setup` configuration, skill installation, end-to-end verification
- Upgrade: package update, skill regeneration, breaking change migrations (e.g. $START new/resume)
+8
View File
@@ -0,0 +1,8 @@
---
"@united-workforce/cli": patch
---
fix: bootstrap adds Step 0 environment pre-flight check
- Pre-flight checks for node, pnpm/npm, global bin PATH, hermes CLI with FIX instructions (#112)
- Install commands changed from npm to pnpm (with npm fallback)
+9
View File
@@ -0,0 +1,9 @@
---
"@united-workforce/cli": patch
"@united-workforce/util": patch
---
fix: workflow-authoring flat schema example uses enum, bootstrap adds PATH guidance
- workflow-authoring: flat schema example uses `enum: [done]` instead of bare `const` (#110.3)
- bootstrap: adds `which hermes` check and PATH guidance for venv installs (#110.4)
+10
View File
@@ -0,0 +1,10 @@
---
"@united-workforce/cli": patch
---
fix: preset provider base-url auto-fill, bootstrap ACP docs, friendlier name mismatch error
- `uwf setup --provider dashscope` now auto-fills `--base-url` from preset list (#106)
- Bootstrap guide documents uwf-hermes ACP dependency (`pip install hermes-agent[acp]`) (#107)
- Bootstrap verify step uses inline workflow instead of missing `examples/eval-simple.yaml` (#107)
- Workflow filename mismatch error now suggests how to fix it (#108)
+12
View File
@@ -0,0 +1,12 @@
---
"@united-workforce/cli": patch
---
fix: setup UX improvements (#114)
- Setup validates adapter availability and prints install command if missing
- Setup prints "Config saved to <path> ✓" on success
- Spawn ENOENT gives actionable error ("not found in PATH" + which command)
- SQLite ExperimentalWarning suppressed via NODE_OPTIONS in spawned processes
- Bootstrap VERSION reads cli package version (was reading util version)
- Bootstrap PATH guidance is shell-agnostic (no hardcoded .bashrc/.profile)
+9
View File
@@ -0,0 +1,9 @@
---
"@united-workforce/cli": minor
"@united-workforce/util": patch
---
feat: replace $START `_` status with `new`/`resume` semantics
BREAKING: All workflow YAML files must update `$START._` to `$START.new` + `$START.resume`.
The `resume` edge prompt replaces the previously hardcoded resume message in the CLI.
+56 -124
View File
@@ -1,131 +1,63 @@
name: debate
description: "Multi-role structured debate with critical thinking framework and host summary."
# Shared frontmatter schema for debater roles (YAML anchor)
x-debater-frontmatter: &debater-frontmatter
type: object
oneOf:
- properties:
$status: { const: speak }
argument: { type: string }
required: [$status, argument]
- properties:
$status: { const: conceded }
reason: { type: string }
required: [$status, reason]
- properties:
$status: { const: final }
closing: { type: string }
required: [$status, closing]
name: "debate"
description: "Structured debate between two sides. Tests cross-process session resume."
roles:
proponent:
description: "Argues FOR the proposition"
goal: "Build a compelling case for the proposition through logical reasoning and evidence"
capabilities: []
against:
description: "Argues against the proposition"
goal: |
You are a skilled debater arguing AGAINST the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
procedure: |
You are an experienced scholar arguing FOR the proposition.
## Critical Thinking Framework (execute before every speech)
### A. Pre-speech reflection (internal, do not output)
- Does every step in my argument chain hold? Any hidden assumptions or logical gaps?
- If I were my opponent, how would I attack this? Where am I weakest?
- Does my evidence actually support my claim, or could it backfire?
- Should I go on offense or defense this round?
### B. Evidence discipline
- Verify key numbers — watch for order-of-magnitude errors
- Assess data freshness — fast-moving fields have short half-lives
- Distinguish primary data from secondary citations, expert opinion, and common assumptions
### C. Anti-fragility
- Anticipate counterarguments; preemptively strengthen or strategically abandon weak points
- Catch logical gaps, data misuse, or outdated claims in your opponent's reasoning
## Rules
1. Check Thread Progress to see how many times you have spoken.
2. On your 3rd speech, you MUST output $status: final (closing statement).
3. If genuinely convinced by the opponent, output $status: conceded.
4. Otherwise output $status: speak and counter the opponent's points.
5. Be rigorous, cite evidence, stay concise.
output: "Debate argument"
frontmatter: *debater-frontmatter
opponent:
description: "Argues AGAINST the proposition"
goal: "Build a compelling case against the proposition through logical reasoning and evidence"
capabilities: []
procedure: |
You are an experienced scholar arguing AGAINST the proposition.
## Critical Thinking Framework (execute before every speech)
### A. Pre-speech reflection (internal, do not output)
- Does every step in my argument chain hold? Any hidden assumptions or logical gaps?
- If I were my opponent, how would I attack this? Where am I weakest?
- Does my evidence actually support my claim, or could it backfire?
- Should I go on offense or defense this round?
### B. Evidence discipline
- Verify key numbers — watch for order-of-magnitude errors
- Assess data freshness — fast-moving fields have short half-lives
- Distinguish primary data from secondary citations, expert opinion, and common assumptions
### C. Anti-fragility
- Anticipate counterarguments; preemptively strengthen or strategically abandon weak points
- Catch logical gaps, data misuse, or outdated claims in your opponent's reasoning
## Rules
1. Check Thread Progress to see how many times you have spoken.
2. On your 3rd speech, or when the proponent has issued a final statement, you MUST output $status: final.
3. If genuinely convinced by the proponent, output $status: conceded.
4. Otherwise output $status: speak and counter the proponent's points.
5. Be rigorous, cite evidence, stay concise.
output: "Debate argument"
frontmatter: *debater-frontmatter
host:
description: "Debate moderator — delivers impartial summary and verdict"
goal: "Objectively review the debate, analyze both sides, and deliver a verdict"
capabilities: []
procedure: |
You are an experienced academic debate moderator.
## Task
1. Outline each side's core arguments
2. Evaluate reasoning quality and evidence use
3. Highlight the most impactful exchanges
4. Analyze the deeper significance of the topic
5. Deliver an overall verdict
## Style
- Impartial but with independent judgment
- Substantive, not superficial
output: "Debate summary report"
1. If this is the opening, present your strongest argument against the proposition.
2. If responding to the other side, directly counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
$status: { const: done }
summary: { type: string }
highlights: { type: string }
verdict: { type: string }
required: [$status, summary, highlights, verdict]
$status:
enum: ["continue", "conceded"]
argument:
type: string
required: [$status, argument]
for:
description: "Argues for the proposition"
goal: |
You are a skilled debater arguing FOR the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
procedure: |
1. Read the opposing side's latest argument carefully.
2. Counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
$status:
enum: ["continue", "conceded"]
argument:
type: string
required: [$status, argument]
graph:
$START:
new: { role: proponent, prompt: "The debate begins. You are arguing FOR the proposition. Present your opening argument." }
resume: { role: proponent, prompt: "The debate continues." }
proponent:
speak: { role: opponent, prompt: "Proponent argues:\n\n{{{argument}}}\n\nYou are the opponent. Counter this argument." }
conceded: { role: host, prompt: "The proponent conceded: {{{reason}}}\n\nPlease summarize the debate." }
final: { role: opponent, prompt: "Proponent's closing statement:\n\n{{{closing}}}\n\nYou are the opponent. Deliver your final response." }
opponent:
speak: { role: proponent, prompt: "Opponent argues:\n\n{{{argument}}}\n\nYou are the proponent. Counter this argument." }
conceded: { role: host, prompt: "The opponent conceded: {{{reason}}}\n\nPlease summarize the debate." }
final: { role: host, prompt: "Opponent's closing statement:\n\n{{{closing}}}\n\nThe debate is over. Please summarize." }
host:
done: { role: "$END", prompt: "Summary complete." }
new: { role: "against", prompt: "Present your opening argument against the proposition." }
resume: { role: "against", prompt: "Review the previous debate output and continue the argument against the proposition." }
against:
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
for:
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-builtin",
"version": "0.1.2",
"version": "0.1.1",
"files": [
"src",
"dist",
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
#!/usr/bin/env node
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-claude-code",
"version": "0.1.3",
"version": "0.1.1",
"files": [
"src",
"dist",
@@ -7,7 +7,6 @@ import {
type AgentRunResult,
buildContinuationPrompt,
buildRolePrompt,
buildThreadProgress,
createAgent,
getCachedSessionId,
setCachedSessionId,
@@ -28,10 +27,6 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
// Inject thread progress so the agent knows step count and role visit count
parts.push(buildThreadProgress(ctx.steps, ctx.role), "");
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
if (!ctx.isFirstVisit) {
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
#!/usr/bin/env node
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
@@ -15,8 +15,7 @@ describe("Issue #551 — bin entry & engines", () => {
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")).toBe(true);
expect(content).toContain("node");
expect(content.startsWith("#!/usr/bin/env node")).toBe(true);
});
test("README.md explains uwf-hermes is an adapter", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-hermes",
"version": "0.1.4",
"version": "0.1.2",
"files": [
"src",
"dist",
+2 -7
View File
@@ -12,11 +12,7 @@ const OWN_VERSION = (
}
).version;
/** Resolve hermes binary: `UWF_HERMES_BIN` override → default `"hermes"` via PATH. */
function resolveHermesCommand(): string {
const override = process.env.UWF_HERMES_BIN;
return override !== undefined && override !== "" ? override : "hermes";
}
const HERMES_COMMAND = "hermes";
const PROTOCOL_VERSION = 1;
type JsonRpcResponse = {
@@ -275,8 +271,7 @@ export class HermesAcpClient {
return;
}
const hermesCommand = resolveHermesCommand();
const child = spawn(hermesCommand, ["acp"], {
const child = spawn(HERMES_COMMAND, ["acp"], {
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"],
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
#!/usr/bin/env node
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
-4
View File
@@ -6,7 +6,6 @@ import {
type AgentRunResult,
buildContinuationPrompt,
buildRolePrompt,
buildThreadProgress,
createAgent,
} from "@united-workforce/util-agent";
import type { AcpUsage } from "./acp-client.js";
@@ -61,9 +60,6 @@ export function buildHermesPrompt(ctx: AgentContext): string {
parts.push(ctx.outputFormatInstruction, "");
}
// Inject thread progress so the agent knows step count and role visit count
parts.push(buildThreadProgress(ctx.steps, ctx.role), "");
if (!ctx.isFirstVisit) {
// Re-entry: show only steps since last visit, meta only
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-mock",
"version": "0.1.2",
"version": "0.1.1",
"files": [
"src",
"dist",
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
#!/usr/bin/env node
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/cli",
"version": "0.3.0",
"version": "0.2.0",
"files": [
"src",
"dist",
+10 -18
View File
@@ -28,13 +28,9 @@ roles:
$status: "ready"
frontmatter:
type: object
oneOf:
- properties:
$status: { const: "ready" }
required: ["$status"]
- properties:
$status: { const: "not-ready" }
required: ["$status"]
required: ["$status"]
properties:
$status: { type: string, enum: ["ready", "not-ready"] }
roleB:
description: Second role
goal: Do B
@@ -46,7 +42,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "done" }
$status: { type: string, enum: ["done"] }
graph:
$START:
new:
@@ -86,13 +82,9 @@ roles:
$status: "pass"
frontmatter:
type: object
oneOf:
- properties:
$status: { const: "pass" }
required: ["$status"]
- properties:
$status: { const: "fail" }
required: ["$status"]
required: ["$status"]
properties:
$status: { type: string, enum: ["pass", "fail"] }
roleB:
description: Pass role
goal: Do B
@@ -104,7 +96,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "done" }
$status: { type: string, enum: ["done"] }
roleC:
description: Fail role
goal: Do C
@@ -116,7 +108,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "done" }
$status: { type: string, enum: ["done"] }
graph:
$START:
new:
@@ -163,7 +155,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "done" }
$status: { type: string, enum: ["done"] }
graph:
$START:
new:
@@ -54,7 +54,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "ready" }
$status: { type: string, enum: ["ready"] }
graph:
$START:
new:
@@ -114,7 +114,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "ready" }
$status: { type: string, enum: ["ready"] }
graph:
$START:
new:
@@ -161,7 +161,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "ready" }
$status: { type: string, enum: ["ready"] }
graph:
$START:
new:
@@ -31,7 +31,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "ready" }
$status: { type: string, enum: ["ready"] }
graph:
$START:
new:
@@ -54,7 +54,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { const: "ready" }
$status: { type: string, enum: ["ready"] }
graph:
$START:
new:
@@ -17,7 +17,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
$status: { enum: ["done"] },
plan: { type: "string" },
},
required: ["$status", "plan"],
@@ -85,7 +85,7 @@ describe("Suite 1: Role Reference Integrity", () => {
output: "None",
frontmatter: {
type: "object",
properties: { $status: { const: "done" } },
properties: { $status: { enum: ["done"] } },
required: ["$status"],
} as unknown as string,
};
@@ -187,7 +187,7 @@ describe("Suite 2: Graph Structure", () => {
output: "Isolated",
frontmatter: {
type: "object",
properties: { $status: { const: "done" } },
properties: { $status: { enum: ["done"] } },
required: ["$status"],
} as unknown as string,
};
@@ -272,8 +272,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
});
});
describe("Suite 3b: Enum-Based $status is Rejected", () => {
test("3b.1 enum multi-exit is rejected (must use oneOf + const)", () => {
describe("Suite 3b: Enum-Based Multi-Exit", () => {
test("3b.1 enum multi-exit passes with matching graph keys", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
@@ -291,10 +291,52 @@ describe("Suite 3b: Enum-Based $status is Rejected", () => {
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
expect(errors).toEqual([]);
});
test("3b.2 enum single-exit is rejected (must use const)", () => {
test("3b.2 enum multi-exit with extra graph key", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
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);
});
test("3b.3 enum multi-exit with missing graph key", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
});
test("3b.4 enum with single explicit value passes", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
@@ -309,71 +351,28 @@ describe("Suite 3b: Enum-Based $status is Rejected", () => {
};
wf.graph.writer = { ready: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
});
});
describe("Suite 3c: Const-Based Flat Schema", () => {
test("3c.1 flat schema with const $status passes validation", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
test("3c.2 flat schema with const $status detects extra graph key", () => {
test("3b.5 enum multi-exit mustache var not in frontmatter", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
plan: { type: "string" },
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "plan"],
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.writer = {
done: { role: "reviewer", prompt: "Review.", location: null },
extra: { role: "$END", prompt: "Nope.", location: null },
wf.graph.reviewer = {
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("extra status keys") && e.includes("extra"))).toBe(true);
});
test("3c.3 flat schema with const $status validates mustache vars", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = {
done: { role: "reviewer", prompt: "Review: {{{nonexistent}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(
errors.some(
(e) => e.includes('prompt variable "nonexistent"') && e.includes('role "writer"'),
),
).toBe(true);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
});
});
@@ -481,7 +480,7 @@ describe("Suite 6: Multiple Errors Collection", () => {
output: "None",
frontmatter: {
type: "object",
properties: { $status: { const: "done" } },
properties: { $status: { enum: ["done"] } },
required: ["$status"],
} as unknown as string,
};
@@ -31,7 +31,7 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
$status: { type: "string", enum: ["done"] },
},
required: ["$status"],
} as unknown as CasRef,
+2 -2
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
#!/usr/bin/env node
import type { CasRef, ThreadId, ThreadStatus } from "@united-workforce/protocol";
import { Command } from "commander";
@@ -542,7 +542,7 @@ prompt
program
.command("setup")
.description("Configure provider, model, and agent. Run without options for interactive wizard.")
.description("Configure provider, model, and agent")
.option("--provider <name>", "Provider name")
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
+25 -114
View File
@@ -14,10 +14,7 @@ function _findCliVersion(): string {
for (let i = 0; i < 5; i++) {
const candidate = join(dir, "package.json");
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
name?: string;
version?: string;
};
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { name?: string; version?: string };
if (pkg.name === "@united-workforce/cli") {
return pkg.version ?? "0.0.0";
}
@@ -78,105 +75,48 @@ npm prefix -g # global prefix; bin is <prefix>/bin
# FIX: add the missing directory to your shell's startup file
# (e.g. ~/.bashrc, ~/.zshrc, ~/.profile, or fish config):
# export PATH="<global-bin-dir>:$PATH"
# 4. (uwf-hermes only) hermes CLI
which hermes
# FIX: if hermes is in a virtualenv, add it to PATH:
# export PATH="$HOME/.hermes/hermes-agent/.venv/bin:$PATH"
# or create a symlink: ln -s ~/.hermes/hermes-agent/.venv/bin/hermes ~/.local/bin/hermes
\`\`\`
**All checks must pass before continuing.** If you had to modify PATH, verify the change persists by opening a new shell or sourcing your shell config.
### Step 1 — Discover agents and install adapter
**First, detect which supported agents are already installed on the user's machine:**
### Step 1 — Install CLI and agent adapter
\`\`\`bash
# Check for Hermes Agent
which hermes 2>/dev/null && hermes --version
# Check for Claude Code
which claude 2>/dev/null && claude --version # should show "X.Y.Z (Claude Code)"
pnpm add -g @united-workforce/cli # or: npm install -g @united-workforce/cli
uwf --version # should print ${CLI_VERSION}
\`\`\`
**Based on the results:**
Install an agent adapter (at least one is required):
- **Only hermes found** → install \`uwf-hermes\` adapter
- **Only claude found** → install \`uwf-claude-code\` adapter
- **Both found** → ask the user which agent they want uwf to use as default
- **Neither found** → the user must install at least one agent first:
- Hermes Agent: https://hermes-agent.nousresearch.com/docs
- Claude Code: \`npm install -g @anthropic-ai/claude-code\`
**Install the uwf CLI and the chosen adapter** using pnpm or npm:
| Adapter | Install | When to use |
|---------|---------|-------------|
| uwf-hermes | \`pnpm add -g @united-workforce/agent-hermes\` | When your agent framework is Hermes Agent |
| uwf-claude-code | \`pnpm add -g @united-workforce/agent-claude-code\` | When using Claude Code CLI directly |
| uwf-builtin | \`pnpm add -g @united-workforce/agent-builtin\` | Lightweight built-in agent (no external dependency) |
**uwf-hermes** also requires the Hermes ACP plugin. After installing \`hermes-agent\`, run:
\`\`\`bash
# CLI (required)
pnpm add -g @united-workforce/cli # or: npm install -g @united-workforce/cli
# Adapter — install the one matching the detected agent:
pnpm add -g @united-workforce/agent-hermes # or: npm i -g @united-workforce/agent-hermes
pnpm add -g @united-workforce/agent-claude-code # or: npm i -g @united-workforce/agent-claude-code
pip install hermes-agent[acp] # or: pip install -e .[acp] if installed from source
\`\`\`
**⚠ Adapter versions are independent from CLI versions.** Do NOT try to match adapter version to CLI version. Just install \`@latest\` (the default).
**After installing, verify that \`uwf\` and the adapter are available in PATH:**
\`\`\`bash
uwf --version # should print ${CLI_VERSION}
uwf-hermes --version # or: uwf-claude-code --version
\`\`\`
If either command is not found, the global bin directory is not in the current shell's PATH. **You must fix this before continuing:**
1. Find where the binary was installed:
\`\`\`bash
find ~/.local ~/.hermes /usr/local -name uwf -type f 2>/dev/null
npm prefix -g # global prefix — bin is <prefix>/bin
\`\`\`
2. Add the directory to PATH permanently by appending to the user's shell config (e.g. \`~/.bashrc\`, \`~/.zshrc\`, \`~/.profile\`, or fish config):
\`\`\`bash
export PATH="<global-bin-dir>:$PATH"
\`\`\`
3. Source the updated config or open a new shell, then re-verify the commands work.
**uwf-hermes** also requires the Hermes ACP plugin. Verify with \`hermes acp --help\`. If not available, install it:
\`\`\`bash
# Option A: install into hermes venv (recommended)
source ~/.hermes/hermes-agent/.venv/bin/activate && pip install hermes-agent[acp]
# Option B: pipx
pipx install 'hermes-agent[acp]'
# Option C: if installed from source
pip install -e '.[acp]'
\`\`\`
Verify the adapter is installed: \`uwf-hermes --version\` (or whichever you chose).
### Step 2 — Configure provider and model
uwf needs an LLM provider to run agents. **Ask the user** for their provider, API key, and model, then run:
\`\`\`bash
uwf setup --provider <name> --api-key <key> --model <model> --agent <adapter-command>
uwf setup --provider <name> --base-url <url> --api-key <key> --model <model> [--agent <adapter>]
\`\`\`
**Note:** \`--agent\` takes the adapter **command name** (e.g. \`uwf-hermes\`), not the npm package name.
**Preset providers** — when using a preset name, \`--base-url\` is auto-filled and can be omitted:
| Provider | Name | Default base URL |
|----------|------|-----------------|
| OpenAI | \`openai\` | https://api.openai.com/v1 |
| xAI | \`xai\` | https://api.x.ai/v1 |
| OpenRouter | \`openrouter\` | https://openrouter.ai/api/v1 |
| Venice | \`venice\` | https://api.venice.ai/api/v1 |
| Dashscope | \`dashscope\` | https://dashscope.aliyuncs.com/compatible-mode/v1 |
| DeepSeek | \`deepseek\` | https://api.deepseek.com/v1 |
| SiliconFlow | \`siliconflow\` | https://api.siliconflow.cn/v1 |
| VolcEngine | \`volcengine\` | https://ark.cn-beijing.volces.com/api/v3 |
| Kimi (Moonshot) | \`kimi\` | https://api.moonshot.cn/v1 |
| GLM (Zhipu AI) | \`glm\` | https://open.bigmodel.cn/api/paas/v4 |
| StepFun | \`stepfun\` | https://api.stepfun.com/v1 |
| MiniMax | \`minimax\` | https://api.minimax.io/v1 |
| Ollama (local) | \`ollama\` | http://localhost:11434/v1 |
For **non-preset providers**, you must specify \`--base-url\` manually.
Preset providers (base-url is auto-filled when using a preset name):
openai, xai, openrouter, venice, dashscope, deepseek, siliconflow, volcengine, kimi, glm, stepfun, minimax, ollama
Example:
\`\`\`bash
@@ -201,8 +141,6 @@ Each command outputs a complete SKILL.md with YAML frontmatter. Use your agent f
Verify skills are installed by listing them (e.g. \`skills_list()\`) and confirming all three appear.
**⚠ After saving all skills, start a new session** so the agent loads the updated skill content. Skills saved in the current session are not active until the next session.
### Step 4 — Verify end-to-end
Create a minimal workflow file to test your setup:
@@ -221,7 +159,7 @@ roles:
frontmatter:
type: object
properties:
$status: { const: done }
$status: { enum: [done] }
message: { type: string }
required: [$status, message]
graph:
@@ -248,25 +186,11 @@ If the thread reaches \`$END\` with status \`completed\`, the setup is working.
### Step 1 — Update packages
\`\`\`bash
# Using pnpm
pnpm add -g @united-workforce/cli@latest
# Using npm
npm install -g @united-workforce/cli@latest
\`\`\`
\`\`\`bash
pnpm add -g @united-workforce/cli@latest # or: npm install -g @united-workforce/cli@latest
uwf --version # should print ${CLI_VERSION}
\`\`\`
Also update your adapter(s):
\`\`\`bash
# pnpm
# Also update your adapter(s)
pnpm add -g @united-workforce/agent-hermes@latest
# npm
npm install -g @united-workforce/agent-hermes@latest
\`\`\`
### Step 2 — Regenerate skills
@@ -279,8 +203,6 @@ uwf prompt workflow-authoring # → update skill "uwf-workflow-authoring"
uwf prompt adapter-developing # → update skill "uwf-adapter-developing"
\`\`\`
**⚠ After updating skills, start a new session** to load the new skill content.
### Step 3 — Migrate workflow YAML files (if needed)
Check the changelog for breaking changes. Known migrations:
@@ -299,17 +221,6 @@ Check the changelog for breaking changes. Known migrations:
Update all \`.workflow/\` and \`.workflows/\` YAML files in your projects. \`uwf workflow add\` will reject files with the old \`_\` syntax.
- **v0.2.1**: \`$status: { enum: [value] }\`\`$status: { const: "value" }\`. The validator no longer accepts \`enum\` for \`$status\`. Update all workflow YAML files:
\`\`\`yaml
# Before (v0.2.0)
$status: { enum: [done] }
$status: { type: string, enum: ["ready", "failed"] }
# After (v0.2.1+)
$status: { const: "done" }
# For multi-exit, use oneOf with const (unchanged)
\`\`\`
### Step 4 — Verify
\`\`\`bash
+2
View File
@@ -443,6 +443,7 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Print config path to stderr (stdout is reserved for JSON output)
// biome-ignore lint/nursery/noConsole: CLI user-facing output
console.error(`Config saved to ${configPath}`);
// Validate model connectivity
@@ -452,6 +453,7 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
const agentName = _agentNameFromBinary(args.agent ?? "hermes");
const adapterWarnings = _checkAdapterAvailability(agentName);
for (const w of adapterWarnings) {
// biome-ignore lint/nursery/noConsole: CLI user-facing output
console.error(`${w}`);
}
+12
View File
@@ -1001,6 +1001,12 @@ function spawnAgent(
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
cwd,
env: {
...process.env,
NODE_OPTIONS: [process.env.NODE_OPTIONS, "--disable-warning=ExperimentalWarning"]
.filter(Boolean)
.join(" "),
},
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
@@ -1248,6 +1254,12 @@ async function cmdThreadStepBackground(
const child = spawn(scriptPath, args, {
detached: true,
stdio: "ignore",
env: {
...process.env,
NODE_OPTIONS: [process.env.NODE_OPTIONS, "--disable-warning=ExperimentalWarning"]
.filter(Boolean)
.join(" "),
},
});
child.unref();
+13 -13
View File
@@ -24,22 +24,22 @@ function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
return Array.isArray(obj.oneOf);
}
/** Check if a frontmatter schema declares "$status" as const (flat schema form). */
function hasStatusConst(fm: unknown): boolean {
/** Check if a frontmatter schema declares "$status" as an enum (the required form for user roles). */
function hasStatusEnum(fm: unknown): boolean {
if (typeof fm !== "object" || fm === null) return false;
const obj = fm as SchemaObj;
const props = obj.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return false;
return typeof props.$status.const === "string";
return Array.isArray(props.$status.enum);
}
/** Extract status values from a const-based $status field. */
function getConstStatuses(fm: SchemaObj): string[] {
/** Extract status values from an enum-based $status field. */
function getEnumStatuses(fm: SchemaObj): string[] {
const props = fm.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return [];
const statusDef = props.$status;
if (typeof statusDef.const === "string") return [statusDef.const];
return [];
if (!Array.isArray(statusDef.enum)) return [];
return statusDef.enum as string[];
}
/** Get property names from a schema object. */
@@ -248,21 +248,21 @@ function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void
checkOneOfDiscriminant(roleName, variants, statuses, errors);
checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
checkMultiExitMustache(roleName, graphEntry, variants, errors);
} else if (hasStatusConst(fm)) {
const statuses = getConstStatuses(fm as SchemaObj);
} else if (hasStatusEnum(fm)) {
const statuses = getEnumStatuses(fm as SchemaObj);
checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
// For const-based flat schemas, mustache vars come from the flat properties
checkFlatMustache(roleName, graphEntry, fm as SchemaObj, errors);
// For enum-based schemas, mustache vars come from the flat properties
checkEnumMustache(roleName, graphEntry, fm as SchemaObj, errors);
} else {
errors.push(
`role "${roleName}" must define "$status" as const (or oneOf with const) in frontmatter`,
`role "${roleName}" must define "$status" as an enum (or oneOf const) in frontmatter`,
);
}
}
}
/** Check mustache vars in all edge prompts against flat schema properties. */
function checkFlatMustache(
function checkEnumMustache(
roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj,
@@ -143,7 +143,7 @@ describe("buildOutputFormatInstruction", () => {
{
type: "object",
properties: {
$status: { const: "approved" },
$status: { type: "string", enum: ["approved"] },
branch: { type: "string" },
},
required: ["$status"],
@@ -151,7 +151,7 @@ describe("buildOutputFormatInstruction", () => {
{
type: "object",
properties: {
$status: { const: "rejected" },
$status: { type: "string", enum: ["rejected"] },
comments: { type: "string" },
},
required: ["$status"],
@@ -225,34 +225,4 @@ describe("buildOutputFormatInstruction", () => {
const result = buildOutputFormatInstruction({});
expect(result).toContain("Focus exclusively on YOUR role");
});
test("renders const value as literal in flat schema example", () => {
const schema = {
type: "object",
properties: {
$status: { type: "string", const: "greeted" },
message: { type: "string" },
},
required: ["$status", "message"],
};
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("$status: greeted");
expect(result).toContain("fixed value");
expect(result).not.toContain("$status: <string>");
});
test("renders const value for non-string types", () => {
const schema = {
type: "object",
properties: {
count: { type: "number", const: 42 },
done: { type: "boolean", const: true },
},
required: ["count", "done"],
};
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("count: 42");
expect(result).toContain("done: true");
expect(result).toContain("fixed value");
});
});
@@ -1,59 +0,0 @@
import type { StepContext } from "@united-workforce/protocol";
import { describe, expect, test } from "vitest";
import { buildThreadProgress } from "../src/build-thread-progress.js";
function makeStep(role: string): StepContext {
return {
role,
output: {},
detail: "0000000000000" as string,
agent: "uwf-mock",
edgePrompt: "",
startedAtMs: 0,
completedAtMs: 0,
cwd: "",
assembledPrompt: null,
usage: null,
content: null,
};
}
describe("buildThreadProgress", () => {
test("first step of thread", () => {
const result = buildThreadProgress([], "proponent");
expect(result).toContain("## Thread Progress");
expect(result).toContain("first step");
expect(result).toContain("first time");
expect(result).toContain("proponent");
});
test("second step, role not seen before", () => {
const steps = [makeStep("opponent")];
const result = buildThreadProgress(steps, "proponent");
expect(result).toContain("Thread step 2");
expect(result).toContain("spoken 0 times");
});
test("role has spoken once before", () => {
const steps = [makeStep("proponent"), makeStep("opponent")];
const result = buildThreadProgress(steps, "proponent");
expect(result).toContain("Thread step 3");
expect(result).toContain("spoken 1 time before");
// singular "time" not "times"
expect(result).not.toContain("1 times");
});
test("role has spoken multiple times", () => {
const steps = [
makeStep("proponent"),
makeStep("opponent"),
makeStep("proponent"),
makeStep("opponent"),
makeStep("proponent"),
makeStep("opponent"),
];
const result = buildThreadProgress(steps, "proponent");
expect(result).toContain("Thread step 7");
expect(result).toContain("spoken 3 times");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/util-agent",
"version": "0.1.1",
"version": "0.1.0",
"files": [
"src",
"dist",
@@ -74,10 +74,6 @@ function collectObjectSchemas(schema: JSONSchema): JSONSchema[] {
}
function resolvePropertySchema(prop: JSONSchema): JSONSchema {
if (prop.const !== undefined) {
return prop;
}
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
return prop;
}
@@ -117,11 +113,6 @@ function buildPropertyExampleLine(prop: SchemaProperty): string {
commentParts.push("required");
}
if (resolved.const !== undefined) {
commentParts.push("fixed value");
return `${prop.name}: ${formatYamlScalar(resolved.const)}${buildPropertyComment(commentParts)}`;
}
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
const enumValues = resolved.enum.map((v) => String(v));
commentParts.push(...enumValues);
@@ -1,27 +0,0 @@
import type { StepContext } from "@united-workforce/protocol";
/**
* Build a compact thread-progress summary so the agent knows where it is
* in the conversation without making tool calls to count steps.
*
* Example output:
* ## Thread Progress
* Thread step 6. You (proponent) have spoken 2 times before this turn.
*/
export function buildThreadProgress(steps: StepContext[], role: string): string {
const totalSteps = steps.length;
const roleVisits = steps.filter((s) => s.role === role).length;
const parts = [`## Thread Progress`];
if (totalSteps === 0) {
parts.push(
`This is the first step of the thread. You (${role}) are speaking for the first time.`,
);
} else {
parts.push(
`Thread step ${totalSteps + 1}. You (${role}) have spoken ${roleVisits} time${roleVisits === 1 ? "" : "s"} before this turn.`,
);
}
return parts.join("\n");
}
-1
View File
@@ -1,7 +1,6 @@
export { buildContinuationPrompt } from "./build-continuation-prompt.js";
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
export { buildRolePrompt } from "./build-role-prompt.js";
export { buildThreadProgress } from "./build-thread-progress.js";
export type { BuildContextMeta } from "./context.js";
export { buildContext, buildContextWithMeta } from "./context.js";
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/util",
"version": "0.1.4",
"version": "0.1.2",
"files": [
"src",
"dist",
-13
View File
@@ -140,18 +140,5 @@ For specific scenarios, run the corresponding \`uwf prompt\` command:
|----------|---------|-------------|
| Writing workflow YAML | \`uwf prompt workflow-authoring\` | Designing roles, conditions, graphs, and edge prompts |
| Building a new agent adapter | \`uwf prompt adapter-developing\` | Creating a new \`uwf-<name>\` CLI adapter |
## Upgrading
\`\`\`bash
# Install the latest version
pnpm add -g @united-workforce/cli@latest @united-workforce/agent-hermes@latest
# or: npm install -g @united-workforce/cli@latest @united-workforce/agent-hermes@latest
# Verify
uwf --version
# Then run uwf prompt bootstrap and follow the upgrade instructions
\`\`\`
`;
}
@@ -28,7 +28,6 @@ roles: # named actors
2. Do that
output: "..." # what the agent should produce
frontmatter: # JSON Schema for structured output
type: object
oneOf:
- properties:
$status: { const: "ready" }
@@ -72,13 +71,10 @@ The \`frontmatter\` field is a standard JSON Schema. It defines the structured f
### \`$status\` Field
\`$status\` is the only standard field. Its value determines which graph edge the moderator follows.
**Multi-exit (oneOf)** — use \`const\` to constrain each variant:
\`$status\` is the only standard field. Its value determines which graph edge the moderator follows. Use \`const\` to constrain each variant:
\`\`\`yaml
frontmatter:
type: object
oneOf:
- properties:
$status: { const: "done" }
@@ -90,25 +86,26 @@ frontmatter:
required: [$status, error]
\`\`\`
**Single-exit (flat schema)** — same syntax, just no \`oneOf\` wrapper:
### 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, use \`enum\` with a single value:
\`\`\`yaml
frontmatter:
type: object
properties:
$status: { const: "done" }
$status:
type: string
enum: [done]
summary: { type: string }
required: [$status, summary]
\`\`\`
**Important rules:**
- \`type: object\` is **required** at the top level of frontmatter (both flat and oneOf)
- \`$status\` always uses \`const: "value"\` — simple and consistent
- \`enum\` is **not supported** for \`$status\` — the validator will reject it
### Custom Fields
Add any fields you need for data passing between roles. These are available in edge prompts via Mustache templates.
Note: \`$status: { const: "done" }\` is **not** valid in flat schemas — the validator requires \`enum\` or \`oneOf\` with \`const\`. Use \`const\` only inside \`oneOf\` variants.
## Graph Routing