Compare commits

...

28 Commits

Author SHA1 Message Date
xiaoju aa454c85dd chore: bump versions for release
CI / check (push) Successful in 2m56s
- @united-workforce/util: 0.1.3 → 0.1.4
- @united-workforce/util-agent: 0.1.0 → 0.1.1
- @united-workforce/agent-hermes: 0.1.3 → 0.1.4
- @united-workforce/agent-claude-code: 0.1.2 → 0.1.3
2026-06-06 04:40:27 +00:00
xiaomo 6dd7d521be Merge pull request 'chore: deduplicate debate frontmatter with YAML anchor' (#135) from chore/debate-yaml-cleanup into main
CI / check (push) Successful in 2m40s
Merge PR #135: chore: deduplicate debate frontmatter with YAML anchor
2026-06-06 04:23:12 +00:00
xiaoju 950dc056d8 chore: deduplicate debate frontmatter with YAML anchor
CI / check (pull_request) Successful in 2m22s
Use &debater-frontmatter anchor for the shared oneOf schema between
proponent and opponent roles. Procedure blocks remain duplicated
since YAML anchors cannot be embedded inside block scalars.

capabilities: [] kept — required by WorkflowPayload type.

Addresses review suggestions from #133.
2026-06-06 04:16:13 +00:00
xiaomo d360b85374 Merge pull request 'docs: upgrade debate example + fix: UWF_HERMES_BIN env support' (#133) from docs/upgrade-debate-example into main
CI / check (push) Successful in 3m1s
Merge PR #133: docs: upgrade debate example + fix: UWF_HERMES_BIN env support
2026-06-06 04:11:13 +00:00
xiaoju 509dfad857 fix: support UWF_HERMES_BIN env var for hermes binary path
CI / check (pull_request) Successful in 3m28s
Replace hardcoded HERMES_COMMAND constant with resolveHermesCommand()
that checks UWF_HERMES_BIN first, falling back to 'hermes' via PATH.

This fixes environments where hermes is installed in a venv or
non-standard location that isn't in the non-login shell PATH
(e.g. ~/.local/bin symlink only available in login shell).

Refs #134
2026-06-06 03:59:08 +00:00
xiaoju 58b84e3b3c docs: upgrade debate example — 3 roles, oneOf routing, bounded termination
CI / check (pull_request) Failing after 11m23s
Replace the original 2-role debate with a 3-role version featuring:
- proponent/opponent/host roles (was: for/against)
- oneOf + const status routing (was: enum)
- Critical thinking framework in procedure (pre-speech reflection,
  evidence discipline, anti-fragility)
- Bounded termination via Thread Progress (3rd speech → final)
- Host role for impartial summary and verdict

Based on xiaonuo's debate workflow design.
2026-06-06 03:30:54 +00:00
xiaomo f821ac99f4 Merge pull request 'docs: add upgrading section to usage reference' (#132) from feat/usage-upgrade-hint into main
CI / check (push) Successful in 2m8s
2026-06-06 03:00:09 +00:00
xiaoju 2c4700c49f docs: add upgrading section to usage reference
CI / check (pull_request) Successful in 2m27s
2026-06-06 02:57:25 +00:00
xiaomo 4410afcd4a Merge pull request 'fix: render const values as literals in output format instruction (#129)' (#130) from fix/129-const-prompt into main
CI / check (push) Successful in 2m29s
2026-06-06 01:44:24 +00:00
xiaoju a0e254a681 fix: render const values as literals in output format instruction (#129)
CI / check (pull_request) Successful in 1m48s
buildOutputFormatInstruction now renders const fields with their actual
value (e.g. $status: greeted) instead of the type placeholder (<string>).
Also adds early return in resolvePropertySchema for const properties.

Fixes #129
2026-06-06 01:12:13 +00:00
xiaomo dd77b40f6c Merge pull request 'feat: inject thread progress into agent prompt (#127)' (#128) from feat/127-inject-turn-count into main
CI / check (push) Successful in 1m44s
2026-06-06 00:53:10 +00:00
xiaoju 5ed6f68e4b feat: inject thread progress into agent prompt (#127)
CI / check (pull_request) Successful in 1m42s
Agents now receive a Thread Progress section showing current step number
and role visit count, eliminating tool calls to count turns.

- util-agent: new buildThreadProgress() helper
- agent-hermes: inject before continuation/first-visit prompt
- agent-claude-code: same injection point

Fixes #127
2026-06-06 00:40:12 +00:00
xiaoju 1ed0bf1f76 chore: clean changesets after v0.3.0 release
CI / check (push) Successful in 1m43s
2026-06-06 00:14:00 +00:00
xiaoju d97840cf8d chore: release cli@0.3.0 util@0.1.3 agent-hermes@0.1.3 agent-claude-code@0.1.2 agent-builtin@0.1.2 agent-mock@0.1.2
CI / check (push) Successful in 1m46s
2026-06-06 00:13:48 +00:00
xiaomo b560818f1a Merge pull request 'fix: bootstrap — session restart hint + v0.2.1 migration note' (#125) from fix/123-session-restart-hint into main
CI / check (push) Successful in 1m42s
2026-06-05 23:54:24 +00:00
xiaoju f989dee85b fix: bootstrap — remind to restart session after skill install/update
CI / check (pull_request) Successful in 1m42s
- Step 3 (fresh install): warn skills not active until new session
- Step 2 (upgrade): same reminder after regenerating skills
- Step 3 (upgrade): add v0.2.1 migration note for enum → const

Refs #123
2026-06-05 23:48:53 +00:00
xiaomo 7e4a59de7e Merge pull request 'fix: workflow-authoring docs — type:object + const vs enum clarity (#123)' (#124) from fix/123-workflow-authoring-docs into main
CI / check (push) Successful in 1m42s
2026-06-05 23:33:57 +00:00
xiaoju 68079cc003 fix: unify $status to const-only, drop enum support (#123)
CI / check (pull_request) Successful in 1m43s
- Validator: hasStatusConst/getConstStatuses replace enum checks
- enum in $status is now rejected with clear error message
- All docs/examples/tests migrated from enum to const/oneOf
- bootstrap hello.yaml updated

Fixes #123
2026-06-05 23:31:56 +00:00
xiaoju 1a37928bb9 fix: workflow-authoring docs — type:object + const vs enum clarity (#123)
CI / check (pull_request) Successful in 1m41s
- Add type:object to all frontmatter examples (flat and oneOf)
- Restructure $status section: Multi-exit (oneOf/const) vs Single-exit (flat/enum)
- Add Important rules box clarifying validation requirements
- Restore Custom Fields subsection

Fixes #123
2026-06-05 23:13:54 +00:00
xiaomo 57511a93fe Merge pull request 'fix: bootstrap agent discovery + adapter version independence (#120)' (#122) from fix/120-agent-discovery into main
CI / check (push) Successful in 1m44s
2026-06-05 22:35:54 +00:00
xiaoju adc3982a4a fix: bootstrap agent discovery + adapter version independence (#120)
CI / check (pull_request) Successful in 1m42s
- Step 1: detect hermes/claude before choosing adapter
- Adapter versions independent from CLI — install @latest
- ACP verification: hermes acp --help
- Remove uwf-builtin (not ready)

Refs #120
2026-06-05 22:29:35 +00:00
xiaomo 4580388270 Merge pull request 'fix: bootstrap docs — pnpm/npm parity, adapter order, preset table (#118)' (#119) from fix/118-bootstrap-ux into main
CI / check (push) Successful in 2m29s
2026-06-05 16:48:47 +00:00
xiaoju caba82fe36 fix: bootstrap PATH fix guidance — find binary location + update shell config (#118 #1)
CI / check (pull_request) Successful in 1m44s
2026-06-05 16:45:33 +00:00
xiaoju 6aee2ed5ef fix: bootstrap docs — pnpm/npm parity, adapter order, preset table (#118)
CI / check (pull_request) Successful in 2m27s
- Show pnpm and npm install commands side-by-side
- Clarify adapter must be installed before uwf setup --agent
- Add version verification steps with PATH troubleshooting
- --agent takes adapter command name (uwf-hermes), not npm package
- Preset providers shown as table with default base URLs
- Non-preset providers must specify --base-url manually

Fixes #118 (#2, #3, #4, #5)
2026-06-05 16:41:35 +00:00
xiaomo 709b9dc1e5 Merge pull request 'fix: suppress ExperimentalWarning, PEP 668 guidance, setup help (#116)' (#117) from fix/116-setup-ux-2 into main
CI / check (push) Successful in 2m21s
2026-06-05 16:15:27 +00:00
xiaoju 7a788a9d90 fix: suppress ExperimentalWarning, PEP 668 guidance, setup help
CI / check (pull_request) Successful in 2m31s
- All 5 CLI bins: shebang --disable-warning=ExperimentalWarning
- Remove NODE_OPTIONS injection from thread.ts spawn (redundant now)
- Bootstrap pip install: venv (recommended) / pipx / source options
- setup --help mentions interactive wizard mode
- Update shebang test to accept -S flag

Fixes #116
2026-06-05 16:12:06 +00:00
xiaomo e5af5e9027 Merge pull request 'fix: setup UX improvements (#114)' (#115) from fix/114-setup-ux into main
CI / check (push) Successful in 2m43s
2026-06-05 15:45:02 +00:00
xiaoju fde87b6274 fix: setup UX improvements — adapter check, ENOENT, SQLite warning, VERSION, PATH docs
CI / check (pull_request) Successful in 2m24s
- 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:42:22 +00:00
39 changed files with 604 additions and 256 deletions
-9
View File
@@ -1,9 +0,0 @@
---
"@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
@@ -1,8 +0,0 @@
---
"@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
@@ -1,9 +0,0 @@
---
"@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
@@ -1,10 +0,0 @@
---
"@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)
-9
View File
@@ -1,9 +0,0 @@
---
"@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.
+124 -56
View File
@@ -1,63 +1,131 @@
name: "debate"
description: "Structured debate between two sides. Tests cross-process session resume."
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]
roles:
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
proponent:
description: "Argues FOR the proposition"
goal: "Build a compelling case for the proposition through logical reasoning and evidence"
capabilities: []
procedure: |
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".
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"
frontmatter:
type: object
properties:
$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]
$status: { const: done }
summary: { type: string }
highlights: { type: string }
verdict: { type: string }
required: [$status, summary, highlights, verdict]
graph:
$START:
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}}}" }
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." }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-builtin",
"version": "0.1.1",
"version": "0.1.2",
"files": [
"src",
"dist",
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// 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.1",
"version": "0.1.3",
"files": [
"src",
"dist",
@@ -7,6 +7,7 @@ import {
type AgentRunResult,
buildContinuationPrompt,
buildRolePrompt,
buildThreadProgress,
createAgent,
getCachedSessionId,
setCachedSessionId,
@@ -27,6 +28,10 @@ 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 node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
@@ -15,7 +15,8 @@ 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 node")).toBe(true);
expect(content.startsWith("#!/usr/bin/env")).toBe(true);
expect(content).toContain("node");
});
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.2",
"version": "0.1.4",
"files": [
"src",
"dist",
+7 -2
View File
@@ -12,7 +12,11 @@ const OWN_VERSION = (
}
).version;
const HERMES_COMMAND = "hermes";
/** 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 PROTOCOL_VERSION = 1;
type JsonRpcResponse = {
@@ -271,7 +275,8 @@ export class HermesAcpClient {
return;
}
const child = spawn(HERMES_COMMAND, ["acp"], {
const hermesCommand = resolveHermesCommand();
const child = spawn(hermesCommand, ["acp"], {
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"],
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
+4
View File
@@ -6,6 +6,7 @@ import {
type AgentRunResult,
buildContinuationPrompt,
buildRolePrompt,
buildThreadProgress,
createAgent,
} from "@united-workforce/util-agent";
import type { AcpUsage } from "./acp-client.js";
@@ -60,6 +61,9 @@ 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.1",
"version": "0.1.2",
"files": [
"src",
"dist",
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// 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.2.0",
"version": "0.3.0",
"files": [
"src",
"dist",
@@ -28,9 +28,13 @@ roles:
$status: "ready"
frontmatter:
type: object
oneOf:
- properties:
$status: { const: "ready" }
required: ["$status"]
- properties:
$status: { const: "not-ready" }
required: ["$status"]
properties:
$status: { type: string, enum: ["ready", "not-ready"] }
roleB:
description: Second role
goal: Do B
@@ -42,7 +46,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
graph:
$START:
new:
@@ -82,9 +86,13 @@ roles:
$status: "pass"
frontmatter:
type: object
oneOf:
- properties:
$status: { const: "pass" }
required: ["$status"]
- properties:
$status: { const: "fail" }
required: ["$status"]
properties:
$status: { type: string, enum: ["pass", "fail"] }
roleB:
description: Pass role
goal: Do B
@@ -96,7 +104,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
roleC:
description: Fail role
goal: Do C
@@ -108,7 +116,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
graph:
$START:
new:
@@ -155,7 +163,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
graph:
$START:
new:
@@ -54,7 +54,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -114,7 +114,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -161,7 +161,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -31,7 +31,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -54,7 +54,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -17,7 +17,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
frontmatter: {
type: "object",
properties: {
$status: { enum: ["done"] },
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
@@ -85,7 +85,7 @@ describe("Suite 1: Role Reference Integrity", () => {
output: "None",
frontmatter: {
type: "object",
properties: { $status: { enum: ["done"] } },
properties: { $status: { const: "done" } },
required: ["$status"],
} as unknown as string,
};
@@ -187,7 +187,7 @@ describe("Suite 2: Graph Structure", () => {
output: "Isolated",
frontmatter: {
type: "object",
properties: { $status: { enum: ["done"] } },
properties: { $status: { const: "done" } },
required: ["$status"],
} as unknown as string,
};
@@ -272,8 +272,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
});
});
describe("Suite 3b: Enum-Based Multi-Exit", () => {
test("3b.1 enum multi-exit passes with matching graph keys", () => {
describe("Suite 3b: Enum-Based $status is Rejected", () => {
test("3b.1 enum multi-exit is rejected (must use oneOf + const)", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
@@ -291,52 +291,10 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
});
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", () => {
test("3b.2 enum single-exit is rejected (must use const)", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
@@ -351,28 +309,71 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
};
wf.graph.writer = { ready: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
});
});
test("3b.5 enum multi-exit mustache var not in frontmatter", () => {
describe("Suite 3c: Const-Based Flat Schema", () => {
test("3c.1 flat schema with const $status passes validation", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "comments"],
required: ["$status", "plan"],
} as unknown as string,
};
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).toEqual([]);
});
test("3c.2 flat schema with const $status detects extra graph key", () => {
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.", location: null },
extra: { role: "$END", prompt: "Nope.", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
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);
});
});
@@ -480,7 +481,7 @@ describe("Suite 6: Multiple Errors Collection", () => {
output: "None",
frontmatter: {
type: "object",
properties: { $status: { enum: ["done"] } },
properties: { $status: { const: "done" } },
required: ["$status"],
} as unknown as string,
};
@@ -31,7 +31,7 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
frontmatter: {
type: "object",
properties: {
$status: { type: "string", enum: ["done"] },
$status: { const: "done" },
},
required: ["$status"],
} as unknown as CasRef,
+2 -2
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
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")
.description("Configure provider, model, and agent. Run without options for interactive wizard.")
.option("--provider <name>", "Provider name")
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
+144 -33
View File
@@ -1,10 +1,35 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
generateAdapterDevelopingReference,
generateUsageReference,
generateWorkflowAuthoringReference,
VERSION,
} from "@united-workforce/util";
// CLI package version (for bootstrap prompt — uwf --version prints this)
// Walk up from __dirname to find the nearest package.json (works from both src/ and dist/)
function _findCliVersion(): string {
let dir = dirname(fileURLToPath(import.meta.url));
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;
};
if (pkg.name === "@united-workforce/cli") {
return pkg.version ?? "0.0.0";
}
} catch {
// not found, keep walking
}
dir = dirname(dir);
}
return "0.0.0";
}
const CLI_VERSION = _findCliVersion();
export {
generateAdapterDevelopingReference as cmdPromptAdapterDeveloping,
generateUsageReference as cmdPromptUsage,
@@ -45,56 +70,113 @@ pnpm --version || npm --version
# 3. Global bin directory is in PATH
# For pnpm:
pnpm root -g 2>/dev/null && pnpm bin -g
pnpm bin -g # prints the global bin directory
# For npm:
npm prefix -g
# The "bin" directory printed above MUST be in your PATH.
# Test: echo $PATH | tr ':' '\\n' | grep -E "(pnpm|npm|node)"
# FIX: add the bin directory to ~/.profile or ~/.bashrc:
# export PATH="$(pnpm bin -g):$PATH" # pnpm
# export PATH="$(npm prefix -g)/bin:$PATH" # npm
# 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
npm prefix -g # global prefix; bin is <prefix>/bin
# The directory printed above MUST be in your PATH.
# Test: echo \\$PATH | tr ':' '\\\\n'
# 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"
\`\`\`
**All checks must pass before continuing.** If you had to modify PATH, verify the change persists by opening a new shell or sourcing your profile.
**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 — Install CLI and agent adapter
### Step 1 — Discover agents and install adapter
**First, detect which supported agents are already installed on the user's machine:**
\`\`\`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)"
\`\`\`
**Based on the results:**
- **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:
\`\`\`bash
# CLI (required)
pnpm add -g @united-workforce/cli # or: npm install -g @united-workforce/cli
uwf --version # should print ${VERSION}
# 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
\`\`\`
Install an agent adapter (at least one is required):
**⚠ Adapter versions are independent from CLI versions.** Do NOT try to match adapter version to CLI version. Just install \`@latest\` (the default).
| 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) |
**After installing, verify that \`uwf\` and the adapter are available in PATH:**
**uwf-hermes** also requires the Hermes ACP plugin. After installing \`hermes-agent\`, run:
\`\`\`bash
pip install hermes-agent[acp] # or: pip install -e .[acp] if installed from source
uwf --version # should print ${CLI_VERSION}
uwf-hermes --version # or: uwf-claude-code --version
\`\`\`
Verify the adapter is installed: \`uwf-hermes --version\` (or whichever you chose).
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]'
\`\`\`
### 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> --base-url <url> --api-key <key> --model <model> [--agent <adapter>]
uwf setup --provider <name> --api-key <key> --model <model> --agent <adapter-command>
\`\`\`
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
**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.
Example:
\`\`\`bash
@@ -119,6 +201,8 @@ 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:
@@ -137,7 +221,7 @@ roles:
frontmatter:
type: object
properties:
$status: { enum: [done] }
$status: { const: done }
message: { type: string }
required: [$status, message]
graph:
@@ -164,11 +248,25 @@ If the thread reaches \`$END\` with status \`completed\`, the setup is working.
### Step 1 — Update packages
\`\`\`bash
pnpm add -g @united-workforce/cli@latest # or: npm install -g @united-workforce/cli@latest
uwf --version # should print ${VERSION}
# Using pnpm
pnpm add -g @united-workforce/cli@latest
# Also update your adapter(s)
# Using npm
npm install -g @united-workforce/cli@latest
\`\`\`
\`\`\`bash
uwf --version # should print ${CLI_VERSION}
\`\`\`
Also update your adapter(s):
\`\`\`bash
# pnpm
pnpm add -g @united-workforce/agent-hermes@latest
# npm
npm install -g @united-workforce/agent-hermes@latest
\`\`\`
### Step 2 — Regenerate skills
@@ -181,6 +279,8 @@ 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:
@@ -199,6 +299,17 @@ 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
+43 -1
View File
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { stdin as input, stdout as output } from "node:process";
@@ -181,7 +182,6 @@ export async function _discoverAgents(): Promise<string[]> {
async function _tryWhichDiscovery(): Promise<string[] | null> {
try {
const { execFileSync } = await import("node:child_process");
const text = execFileSync("which", ["-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
@@ -397,6 +397,37 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
};
}
/**
* Check if the configured adapter binary (and its dependencies) are in PATH.
* Returns warnings array — empty means all good.
*/
export function _checkAdapterAvailability(agentName: string): string[] {
const warnings: string[] = [];
const binary = `uwf-${agentName}`;
try {
execFileSync("which", [binary], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
} catch {
warnings.push(
`${binary} not found in PATH. Install it: pnpm add -g @united-workforce/agent-${agentName}`,
);
return warnings; // skip dependency check if adapter itself is missing
}
// uwf-hermes depends on hermes CLI
if (agentName === "hermes") {
try {
execFileSync("which", ["hermes"], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
} catch {
warnings.push(
'hermes CLI not found in PATH (required by uwf-hermes). Fix: export PATH="$HOME/.hermes/hermes-agent/.venv/bin:$PATH"',
);
}
}
return warnings;
}
/**
* Non-interactive setup. All required args provided via CLI flags.
*/
@@ -411,15 +442,26 @@ 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)
console.error(`Config saved to ${configPath}`);
// Validate model connectivity
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
// Check adapter availability
const agentName = _agentNameFromBinary(args.agent ?? "hermes");
const adapterWarnings = _checkAdapterAvailability(agentName);
for (const w of adapterWarnings) {
console.error(`${w}`);
}
return {
configPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
validation,
adapterWarnings,
};
}
+6
View File
@@ -1004,6 +1004,12 @@ function spawnAgent(
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
if (err.code === "ENOENT") {
failStep(
plog,
`"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`,
);
}
const stderr =
err.stderr == null
? ""
+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 an enum (the required form for user roles). */
function hasStatusEnum(fm: unknown): boolean {
/** Check if a frontmatter schema declares "$status" as const (flat schema form). */
function hasStatusConst(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 Array.isArray(props.$status.enum);
return typeof props.$status.const === "string";
}
/** Extract status values from an enum-based $status field. */
function getEnumStatuses(fm: SchemaObj): string[] {
/** Extract status values from a const-based $status field. */
function getConstStatuses(fm: SchemaObj): string[] {
const props = fm.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return [];
const statusDef = props.$status;
if (!Array.isArray(statusDef.enum)) return [];
return statusDef.enum as string[];
if (typeof statusDef.const === "string") return [statusDef.const];
return [];
}
/** 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 (hasStatusEnum(fm)) {
const statuses = getEnumStatuses(fm as SchemaObj);
} else if (hasStatusConst(fm)) {
const statuses = getConstStatuses(fm as SchemaObj);
checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
// For enum-based schemas, mustache vars come from the flat properties
checkEnumMustache(roleName, graphEntry, fm as SchemaObj, errors);
// For const-based flat schemas, mustache vars come from the flat properties
checkFlatMustache(roleName, graphEntry, fm as SchemaObj, errors);
} else {
errors.push(
`role "${roleName}" must define "$status" as an enum (or oneOf const) in frontmatter`,
`role "${roleName}" must define "$status" as const (or oneOf with const) in frontmatter`,
);
}
}
}
/** Check mustache vars in all edge prompts against flat schema properties. */
function checkEnumMustache(
function checkFlatMustache(
roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj,
@@ -143,7 +143,7 @@ describe("buildOutputFormatInstruction", () => {
{
type: "object",
properties: {
$status: { type: "string", enum: ["approved"] },
$status: { const: "approved" },
branch: { type: "string" },
},
required: ["$status"],
@@ -151,7 +151,7 @@ describe("buildOutputFormatInstruction", () => {
{
type: "object",
properties: {
$status: { type: "string", enum: ["rejected"] },
$status: { const: "rejected" },
comments: { type: "string" },
},
required: ["$status"],
@@ -225,4 +225,34 @@ 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");
});
});
@@ -0,0 +1,59 @@
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.0",
"version": "0.1.1",
"files": [
"src",
"dist",
@@ -74,6 +74,10 @@ 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;
}
@@ -113,6 +117,11 @@ 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);
@@ -0,0 +1,27 @@
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,6 +1,7 @@
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.2",
"version": "0.1.4",
"files": [
"src",
"dist",
+13
View File
@@ -140,5 +140,18 @@ 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,6 +28,7 @@ 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" }
@@ -71,10 +72,13 @@ 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. Use \`const\` to constrain each variant:
\`$status\` is the only standard field. Its value determines which graph edge the moderator follows.
**Multi-exit (oneOf)** — use \`const\` to constrain each variant:
\`\`\`yaml
frontmatter:
type: object
oneOf:
- properties:
$status: { const: "done" }
@@ -86,26 +90,25 @@ frontmatter:
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, use \`enum\` with a single value:
**Single-exit (flat schema)** — same syntax, just no \`oneOf\` wrapper:
\`\`\`yaml
frontmatter:
type: object
properties:
$status:
type: string
enum: [done]
$status: { const: "done" }
summary: { type: string }
required: [$status, summary]
\`\`\`
Note: \`$status: { const: "done" }\` is **not** valid in flat schemas — the validator requires \`enum\` or \`oneOf\` with \`const\`. Use \`const\` only inside \`oneOf\` variants.
**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.
## Graph Routing