Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoju e67932c83c fix: accept omitted condition in fallback transitions
Fallback transitions (last entry in graph node) omit the condition
field in YAML, resulting in undefined instead of null. The validator
and materializer now handle this:

- validate.ts: accept undefined as valid condition value
- workflow.ts: normalizeGraph() coerces undefined → null before CAS put

This was broken by the graph fallback pattern introduced in #370.
2026-05-22 07:38:24 +00:00
xiaomo 04a12231c3 Merge pull request 'feat: register $first/$last JSONata functions in moderator' (#377) from feat/376-first-last-jsonata into main 2026-05-22 07:32:17 +00:00
xiaoju e5ae9a134c feat: register $first/$last JSONata functions in moderator
Register custom $first(role) and $last(role) functions in the JSONata
evaluator. These search the steps array and return the matching role's
frontmatter (output) directly, replacing verbose steps[-1].output.x
expressions with semantic $last('role').field syntax.

- workflow-moderator: register functions via expr.registerFunction()
- Updated all condition expressions in .workflows/ and examples/
- Added tests for $last, $first, and unmatched role (undefined)

Fixes #376
2026-05-22 06:29:56 +00:00
xiaomo bdafaf3aa1 Merge pull request 'refactor!: rename RoleDefinition.meta → frontmatter' (#375) from refactor/374-meta-to-frontmatter into main 2026-05-22 06:06:06 +00:00
xiaoju 02f7f0b708 refactor!: rename RoleDefinition.meta → frontmatter
BREAKING CHANGE: All workflow YAML files must use 'frontmatter' instead of 'meta'.

- workflow-protocol: RoleDefinition.meta → frontmatter, schema updated
- cli-workflow: validate.ts, workflow.ts — resolveMetaRef → resolveFrontmatterRef
- workflow-agent-kit: run.ts — metaSchema → frontmatterSchema
- All YAML files updated (examples/, .workflows/)

Fixes #374
2026-05-22 06:05:07 +00:00
xiaoju 8ea554bb5e Merge pull request 'feat: create .workflows/solve-issue.yaml' (#372) from feat/370-solve-issue-workflow into main 2026-05-22 06:02:15 +00:00
10 changed files with 219 additions and 41 deletions
+11 -11
View File
@@ -23,7 +23,7 @@ roles:
1. Store it via `uwf cas put "<markdown content>"` and capture the returned hash
2. Put the hash in meta.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
meta:
frontmatter:
type: object
properties:
status:
@@ -45,7 +45,7 @@ roles:
5. Ensure `bun run build` passes with no errors
6. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
meta:
frontmatter:
type: object
properties:
status:
@@ -75,7 +75,7 @@ roles:
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
meta:
frontmatter:
type: object
properties:
approved:
@@ -95,7 +95,7 @@ roles:
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
meta:
frontmatter:
type: object
properties:
status:
@@ -115,7 +115,7 @@ roles:
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
meta:
frontmatter:
type: object
properties:
success:
@@ -124,22 +124,22 @@ roles:
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "steps[-1].output.status = 'insufficient_info'"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "steps[-1].output.status = 'failed'"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "steps[-1].output.status = 'fix_code'"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "steps[-1].output.status = 'fix_spec'"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "steps[-1].output.success = false"
expression: "$last('committer').success = false"
graph:
$START:
- role: "planner"
+1 -1
View File
@@ -19,7 +19,7 @@ roles:
output: |
Provide your analysis as markdown under the frontmatter.
The frontmatter must include your structured findings.
meta:
frontmatter:
type: object
properties:
thesis:
+4 -4
View File
@@ -9,7 +9,7 @@ roles:
- planning
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
meta:
frontmatter:
type: object
properties:
plan:
@@ -28,7 +28,7 @@ roles:
- testing
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
meta:
frontmatter:
type: object
properties:
filesChanged:
@@ -46,7 +46,7 @@ roles:
- static-analysis
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
output: "Approve or reject with detailed comments explaining your decision."
meta:
frontmatter:
type: object
properties:
approved:
@@ -57,7 +57,7 @@ roles:
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
expression: "$last('reviewer').approved = false"
graph:
$START:
- role: "planner"
+34 -8
View File
@@ -2,7 +2,12 @@ import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/workflow-protocol";
import type {
CasRef,
RoleDefinition,
Transition,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import {
@@ -46,11 +51,28 @@ function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function resolveMetaRef(uwf: UwfStore, roleName: string, meta: unknown): Promise<CasRef> {
if (!isJsonSchema(meta)) {
fail(`role "${roleName}": meta must be a JSON Schema object`);
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
const result: Record<string, Transition[]> = {};
for (const [node, transitions] of Object.entries(graph)) {
result[node] = transitions.map((t) => ({
role: t.role,
condition: t.condition ?? null,
}));
}
const schema: JSONSchema = meta.title === undefined ? { ...meta, title: roleName } : meta;
return result;
}
async function resolveFrontmatterRef(
uwf: UwfStore,
roleName: string,
frontmatter: unknown,
): Promise<CasRef> {
if (!isJsonSchema(frontmatter)) {
fail(`role "${roleName}": frontmatter must be a JSON Schema object`);
}
const schema: JSONSchema =
frontmatter.title === undefined ? { ...frontmatter, title: roleName } : frontmatter;
return putSchema(uwf.store, schema);
}
@@ -60,14 +82,18 @@ export async function materializeWorkflowPayload(
): Promise<WorkflowPayload> {
const roles: Record<string, RoleDefinition> = {};
for (const [roleName, role] of Object.entries(raw.roles)) {
const meta = await resolveMetaRef(uwf, `${raw.name}.${roleName}`, role.meta);
const frontmatter = await resolveFrontmatterRef(
uwf,
`${raw.name}.${roleName}`,
role.frontmatter,
);
roles[roleName] = {
description: role.description,
goal: role.goal,
capabilities: role.capabilities,
procedure: role.procedure,
output: role.output,
meta,
frontmatter,
};
}
return {
@@ -75,7 +101,7 @@ export async function materializeWorkflowPayload(
description: raw.description,
roles,
conditions: raw.conditions,
graph: raw.graph,
graph: normalizeGraph(raw.graph),
};
}
+7 -4
View File
@@ -15,8 +15,8 @@ function isRoleDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const meta = value.meta;
const metaOk = isRecord(meta) && typeof meta.type === "string";
const frontmatter = value.frontmatter;
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
const capabilities = value.capabilities;
const capabilitiesOk =
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
@@ -26,7 +26,7 @@ function isRoleDefinition(value: unknown): boolean {
capabilitiesOk &&
typeof value.procedure === "string" &&
typeof value.output === "string" &&
metaOk
frontmatterOk
);
}
@@ -42,7 +42,10 @@ function isTransition(value: unknown): boolean {
return false;
}
const condition = value.condition;
return typeof value.role === "string" && (condition === null || typeof condition === "string");
return (
typeof value.role === "string" &&
(condition === null || condition === undefined || typeof condition === "string")
);
}
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
+9 -4
View File
@@ -130,13 +130,18 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
fail(`unknown role: ${role}`);
}
const metaSchema = getSchema(ctx.meta.store, roleDef.meta);
if (metaSchema !== null) {
ctx.outputFormatInstruction = buildOutputFormatInstruction(metaSchema);
const frontmatterSchema = getSchema(ctx.meta.store, roleDef.frontmatter);
if (frontmatterSchema !== null) {
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
}
const agentResult = await runAgent(options, ctx);
const outputHash = await extractOutput(agentResult.output, roleDef.meta, storageRoot, ctx);
const outputHash = await extractOutput(
agentResult.output,
roleDef.frontmatter,
storageRoot,
ctx,
);
const stepHash = await persistStep({
ctx,
outputHash,
@@ -35,11 +35,11 @@ const solveIssueWorkflow: WorkflowPayload = {
conditions: {
needsClarification: {
description: "Planner requests clarification from user",
expression: "$exists(steps[-1].output.needsClarification)",
expression: "$exists($last('planner').needsClarification)",
},
notApproved: {
rejected: {
description: "Reviewer rejected the implementation",
expression: "steps[-1].output.approved = false",
expression: "$last('reviewer').approved = false",
},
},
graph: {
@@ -50,7 +50,7 @@ const solveIssueWorkflow: WorkflowPayload = {
],
developer: [{ role: "reviewer", condition: null }],
reviewer: [
{ role: "developer", condition: "notApproved" },
{ role: "developer", condition: "rejected" },
{ role: "$END", condition: null },
],
},
@@ -72,7 +72,7 @@ describe("evaluate", () => {
expect(result).toEqual({ ok: true, value: "planner" });
});
test("condition match (notApproved → developer)", async () => {
test("condition match (rejected → developer)", async () => {
const context = makeContext([
{
role: "reviewer",
@@ -126,4 +126,116 @@ describe("evaluate", () => {
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
test("$last returns most recent matching role's frontmatter", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
devFailed: {
description: "Developer failed",
expression: "$last('developer').status = 'failed'",
},
},
graph: {
$START: [{ role: "developer", condition: null }],
developer: [
{ role: "$END", condition: "devFailed" },
{ role: "reviewer", condition: null },
],
},
};
const context = makeContext([
{
role: "developer",
output: { status: "done" },
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
},
{
role: "reviewer",
output: { approved: false },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
{
role: "developer",
output: { status: "failed" },
detail: "3QNTH7WK8D2PA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
expect(result).toEqual({ ok: true, value: "$END" });
});
test("$first returns earliest matching role's frontmatter", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
firstPlanReady: {
description: "First planner run was ready",
expression: "$first('planner').status = 'ready'",
},
},
graph: {
$START: [{ role: "planner", condition: null }],
planner: [
{ role: "$END", condition: "firstPlanReady" },
{ role: "developer", condition: null },
],
},
};
const context = makeContext([
{
role: "planner",
output: { status: "ready", plan: "ABC123" },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
{
role: "developer",
output: { status: "done" },
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
},
{
role: "planner",
output: { status: "revised", plan: "DEF456" },
detail: "4RNMK6PX8B3WQ",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
expect(result).toEqual({ ok: true, value: "$END" });
});
test("$last returns undefined for unmatched role", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
hasReviewer: {
description: "Reviewer has run",
expression: "$exists($last('reviewer'))",
},
},
graph: {
$START: [{ role: "planner", condition: null }],
planner: [
{ role: "$END", condition: "hasReviewer" },
{ role: "developer", condition: null },
],
},
};
const context = makeContext([
{
role: "planner",
output: { status: "ready" },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
// no reviewer step → $exists returns false → fallback to developer
expect(result).toEqual({ ok: true, value: "developer" });
});
});
+33 -1
View File
@@ -21,12 +21,44 @@ function isTruthy(value: unknown): boolean {
return true;
}
function findByRole(
steps: ModeratorContext["steps"],
role: string,
direction: "first" | "last",
): unknown {
if (direction === "last") {
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].role === role) {
return steps[i].output;
}
}
} else {
for (const step of steps) {
if (step.role === role) {
return step.output;
}
}
}
return undefined;
}
async function evaluateJsonata(
expression: string,
context: ModeratorContext,
): Promise<Result<unknown, Error>> {
try {
const result = await jsonata(expression).evaluate(context);
const expr = jsonata(expression);
expr.registerFunction(
"first",
(role: string) => findByRole(context.steps, role, "first"),
"<s:x>",
);
expr.registerFunction(
"last",
(role: string) => findByRole(context.steps, role, "last"),
"<s:x>",
);
const result = await expr.evaluate(context);
return { ok: true, value: result };
} catch (error) {
return {
+2 -2
View File
@@ -2,14 +2,14 @@ import type { JSONSchema } from "@uncaged/json-cas";
const ROLE_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "goal", "capabilities", "procedure", "output", "meta"],
required: ["description", "goal", "capabilities", "procedure", "output", "frontmatter"],
properties: {
description: { type: "string" },
goal: { type: "string" },
capabilities: { type: "array", items: { type: "string" } },
procedure: { type: "string" },
output: { type: "string" },
meta: { type: "string", format: "cas_ref" },
frontmatter: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
+1 -1
View File
@@ -22,7 +22,7 @@ export type RoleDefinition = {
capabilities: string[];
procedure: string;
output: string;
meta: CasRef;
frontmatter: CasRef;
};
export type Transition = {