Compare commits

..

22 Commits

Author SHA1 Message Date
xingyue a5c09adae6 docs: add README.md to all 8 packages
Each README includes: package description, install instructions,
usage example, API overview, and (for templates) role/moderator flow.

Packages documented:
- @uncaged/workflow (core)
- @uncaged/cli-workflow (CLI)
- @uncaged/workflow-agent-cursor
- @uncaged/workflow-agent-hermes
- @uncaged/workflow-agent-llm
- @uncaged/workflow-template-develop
- @uncaged/workflow-template-solve-issue
- @uncaged/workflow-util-agent
2026-05-08 10:17:40 +08:00
xiaoju f81e2a8aac Merge pull request 'chore: enforce folder module discipline in @uncaged/cli-workflow' (#109) from chore/108-cli-module-discipline into main 2026-05-08 01:46:03 +00:00
xiaoju 2b38e583be chore: enforce folder module discipline in @uncaged/cli-workflow
Each commands/ subfolder (cas, init, thread, workflow) now has:
- types.ts for all type definitions
- index.ts with pure re-exports only
- External imports go through index.ts

Closes #108
2026-05-08 01:42:32 +00:00
xiaoju 4ff1394224 Merge pull request 'chore: enforce folder module discipline in @uncaged/workflow' (#107) from chore/106-workflow-module-discipline into main 2026-05-08 01:39:48 +00:00
xiaoju 2bbe5a3d0e chore: enforce folder module discipline in @uncaged/workflow
Each folder now has:
- types.ts for all type definitions
- index.ts with pure re-exports only
- Cross-folder imports go through index.ts

Closes #106
2026-05-08 01:37:23 +00:00
xiaoju a4237c0462 docs: add folder module discipline rules to CLAUDE.md
Four rules: index.ts entry point, types.ts for types, single export source,
index.ts is pure re-exports. Also fix stale build command reference.

Refs #102
2026-05-08 01:29:22 +00:00
xiaomo 321e5b1379 Merge pull request 'chore(cli): remove unused <thread-id> from CAS commands' (#105) from chore/cleanup-cas-thread-id into main 2026-05-08 01:25:33 +00:00
xingyue 7c3e14c473 chore(cli): remove unused <thread-id> from CAS commands
CAS is global (not per-thread). The underlying cmdCas* functions
already dropped threadId in #103, but the CLI dispatch layer still
required it from users. Now cleaned up:

- cas get <hash> (was: cas get <thread-id> <hash>)
- cas put <content> (was: cas put <thread-id> <content>)
- cas list (was: cas list <thread-id>)
- cas rm <hash> (was: cas rm <thread-id> <hash>)
- skill.ts develop topic updated to match
2026-05-08 09:23:39 +08:00
xiaoju aecce595e8 Merge pull request 'refactor: organize workflow/src into 6 module folders' (#104) from refactor/102-module-folders into main 2026-05-08 01:23:24 +00:00
xiaoju cf17dedac3 refactor: organize workflow/src into 6 module folders
Move 34 flat modules into cas/, registry/, bundle/, extract/, engine/, util/.
Move gc.ts to engine/ (was in cas/) to avoid cas→engine reverse dependency.
Dependency direction: util ← cas ← extract ← engine, util ← registry ← bundle.
No logic changes — only file locations and import paths.

Refs #102
2026-05-08 01:22:01 +00:00
xiaomo 661fdbb263 Merge pull request 'refactor(cli): Phase 4 cleanup — dedup, extract, deprecate' (#103) from refactor/97-phase4-cleanup into main 2026-05-08 01:17:17 +00:00
xingyue 201abf98ce refactor(cli): Phase 4 cleanup — dedup, extract, deprecate
- bundle-store.ts: remove private pathExists, import from fs-utils
- thread-scan.ts: extract parseFirstJsonLineObject helper, dedup first-line parsing
- commands/workflow/add-argv.ts: extract parseAddArgv from add.ts
- commands/thread/fork-argv.ts: extract parseForkArgv from fork.ts
- commands/cas/*.ts: remove unused _threadId params from cmdCas* functions
- cli-dispatch.ts: add deprecation warning to help command
- commands/init/templates.ts: extract template strings from template.ts
- cli-color.ts: extract shouldUseColor, highlightLiveRole, dimGreyLine from live.ts
- 242 tests pass, biome clean

Closes #97
2026-05-08 09:14:40 +08:00
xiaomo 665965fd01 Merge pull request 'refactor(cli): split cli-dispatch.ts into group dispatchers + usage module' (#101) from refactor/96-phase3-split-dispatch into main 2026-05-08 01:07:04 +00:00
xingyue 6a99f84025 refactor(cli): split cli-dispatch.ts into group dispatchers + usage module
- cli-dispatch.ts: 775 → 149 lines (top-level routing only)
- cli-usage.ts: usage formatting (formatCliUsage, formatUsageCommandLines)
- cli-command-types.ts: shared types (DispatchFn, CommandEntry, CommandGroup)
- cli-registry.ts: getCommandRegistry() assembling all group tables
- cli-usage-context.ts: decouple usage from registry (avoids circular deps)
- commands/{workflow,thread,cas,init}/dispatch.ts: group-specific dispatch
  functions + subcommand tables
- 242 tests pass, CLI output identical, biome clean

Refs #96
2026-05-08 09:04:27 +08:00
xiaomo f61474bec0 Merge pull request 'refactor(cli): merge kill/pause/resume into control.ts + extract readWorkerCtl' (#100) from refactor/95-phase2-control-merge into main 2026-05-08 00:58:11 +00:00
xingyue 9bdb18afd0 refactor(cli): merge kill/pause/resume into control.ts + extract readWorkerCtl
- Merge three near-identical files (kill.ts, pause.ts, resume.ts) into
  commands/thread/control.ts with parameterized cmdThreadControl()
- Extract readWorkerCtl() into worker-spawn.ts to eliminate duplicated
  WorkerCtl parsing logic
- Update cli-dispatch.ts and test imports
- Net reduction: ~59 lines

Refs #95
2026-05-08 08:55:25 +08:00
xiaomo 2af299f3ce Merge pull request 'refactor(cli): restructure cmd-*.ts into commands/ subdirectories' (#98) from refactor/93-phase1-directory-restructure into main 2026-05-08 00:48:30 +00:00
xiaoju d9f79c60a1 Merge pull request 'chore: remove unused build scripts' (#99) from chore/remove-build-scripts into main 2026-05-08 00:46:54 +00:00
xiaoju a47ed06ea5 Merge pull request 'docs: create README.md, update architecture.md for current structure' (#89) from docs/88-readme-architecture-cleanup into main 2026-05-08 00:42:16 +00:00
xingyue 2ef004eecf refactor(cli): restructure cmd-*.ts into commands/ subdirectories
Reorganize flat cmd-*.ts files into commands/{workflow,thread,cas,init}/
subdirectories that strictly mirror the CLI subcommand hierarchy:

- workflow/: add, list, show, rm, history, rollback
- thread/: run, list, show, rm, fork, ps, kill, live, pause, resume
- cas/: get, put, list, rm, gc
- init/: workspace, template

Each group has an index.ts re-export. Split multi-command files
(cmd-cas.ts, cmd-thread.ts, cmd-init.ts) into per-subcommand files.
Rename cmd-help.ts → skill.ts to match the primary command name.

Update all import paths in cli-dispatch.ts and test files.

Pure structural change — no logic modifications.

Ref: #93, closes #94
2026-05-08 00:36:54 +08:00
xiaoju 2616259a0f Merge pull request 'feat(reviewer): enrich prompt with conventions + CLI awareness' (#92) from feat/91-reviewer-prompt into main 2026-05-07 16:27:08 +00:00
xiaoju 23b2c3b47d feat(reviewer): enrich prompt with conventions awareness + strict verdicts
- Read preparer's conventions from thread context
- Review checklist: correctness, conventions, consistency, edge cases
- No nits: every issue is blocking, approve only at zero issues
- Generic prompt, no workflow-specific concepts

Closes #91

小橘 🍊
2026-05-07 16:25:31 +00:00
133 changed files with 2315 additions and 1621 deletions
+31 -2
View File
@@ -97,6 +97,36 @@ type WorkflowEntry = {
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`. Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
### Folder Module Discipline
Every folder under `src/` is a **module boundary**. Four rules:
| # | Rule | Rationale |
|---|------|-----------|
| 1 | **Every folder exports via `index.ts`** | Single entry point for the module |
| 2 | **Types live in `types.ts`** | Each folder's type definitions go in `<folder>/types.ts`, not scattered across files |
| 3 | **Single export source** | Only `index.ts` may re-export. No file may re-export from another module's internals. Cross-module imports must go through `index.ts` — never reach past it to import a specific file |
| 4 | **`index.ts` is pure re-exports** | No type definitions, no function implementations — only `export { ... } from` statements |
```typescript
// ✅ Good — import through module boundary
import { createCasStore } from "../cas/index.js";
import type { CasStore } from "../cas/index.js";
// ❌ Bad — reaching past index.ts
import { createCasStore } from "../cas/cas.js";
// ❌ Bad — re-exporting from non-index file
// in engine/engine.ts:
export { createCasStore } from "../cas/cas.js";
// ❌ Bad — types defined in index.ts
// in cas/index.ts:
export type CasStore = { ... }; // should be in cas/types.ts
```
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
## Naming ## Naming
| Type | Style | Example | | Type | Style | Example |
@@ -197,9 +227,8 @@ Test files (`__tests__/**`) are exempt.
### Commands ### Commands
```bash ```bash
bun run check # biome check (lint + format) bun run check # tsc --build + biome check
bun run format # biome format --write bun run format # biome format --write
bun run build # full build
bun test # run tests bun test # run tests
``` ```
+76
View File
@@ -0,0 +1,76 @@
# @uncaged/cli-workflow
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
## Install
```bash
bun add @uncaged/cli-workflow
```
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
## Usage
```bash
uncaged-workflow workflow list
uncaged-workflow run <name> --prompt "Your task"
uncaged-workflow thread show <id>
uncaged-workflow skill
```
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
```
uncaged-workflow — workflow engine CLI
Workflow registry:
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
workflow list List all registered workflows
workflow show <name> Show details of a registered workflow
workflow rm <name> Remove a workflow from the registry
workflow history <name> Show version history of a workflow
workflow rollback <name> [hash] Rollback a workflow to a previous version
Thread execution:
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
thread list [name] List threads, optionally filtered by workflow name
thread show <id> Show thread details and state
thread rm <id> Remove a thread
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
thread ps List running threads
thread kill <thread-id> Kill a running thread
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
thread pause <thread-id> Pause a running thread
thread resume <thread-id> Resume a paused thread
Content-addressable storage:
cas get <hash> Retrieve content by hash from CAS
cas put <content> Store content in CAS, prints hash
cas list List all hashes in CAS
cas rm <hash> Remove a CAS entry by hash
cas gc Garbage-collect unreferenced CAS entries
Development:
init workspace <name> Initialize a new workflow workspace
init template <name> Initialize a new workflow template
Shortcuts:
run <name> [...] → thread run
live <id> [...] → thread live
Reference:
skill [topic] Agent-consumable docs (cli, develop, author)
Use <command> --help for subcommand details.
Environment variables:
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
```
## API overview
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts``runCli` in `src/cli-dispatch.js`.
@@ -1,4 +1,4 @@
import type { ParsedAddArgv } from "../src/cmd-add.js"; import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
export function addCliArgs(name: string, filePath: string): ParsedAddArgv { export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
return { name, filePath, typesPath: null }; return { name, filePath, typesPath: null };
@@ -4,13 +4,16 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow"; import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
import { cmdAdd } from "../src/cmd-add.js"; import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/cmd-cas.js"; import {
import { cmdHistory } from "../src/cmd-history.js"; cmdAdd,
import { cmdList, formatListLines } from "../src/cmd-list.js"; cmdHistory,
import { cmdRemove } from "../src/cmd-remove.js"; cmdList,
import { cmdRollback } from "../src/cmd-rollback.js"; cmdRemove,
import { cmdShow } from "../src/cmd-show.js"; cmdRollback,
cmdShow,
formatListLines,
} from "../src/commands/workflow/index.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} }; const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
@@ -399,7 +402,7 @@ export const run = async function* (input, options) {
}); });
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => { test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
const put = await cmdCasPut(storageRoot, "nonexistent-thread-id", "phase doc"); const put = await cmdCasPut(storageRoot, "phase doc");
expect(put.ok).toBe(true); expect(put.ok).toBe(true);
if (!put.ok) { if (!put.ok) {
return; return;
@@ -408,24 +411,24 @@ export const run = async function* (input, options) {
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`); const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
expect(await readFile(blobPath, "utf8")).toBe("phase doc"); expect(await readFile(blobPath, "utf8")).toBe("phase doc");
const got = await cmdCasGet(storageRoot, "other-thread", hash); const got = await cmdCasGet(storageRoot, hash);
expect(got.ok).toBe(true); expect(got.ok).toBe(true);
if (!got.ok) { if (!got.ok) {
return; return;
} }
expect(got.value).toBe("phase doc"); expect(got.value).toBe("phase doc");
const listed = await cmdCasList(storageRoot, "another-thread"); const listed = await cmdCasList(storageRoot);
expect(listed.ok).toBe(true); expect(listed.ok).toBe(true);
if (!listed.ok) { if (!listed.ok) {
return; return;
} }
expect(listed.value).toContain(hash); expect(listed.value).toContain(hash);
const removed = await cmdCasRm(storageRoot, "rm-thread", hash); const removed = await cmdCasRm(storageRoot, hash);
expect(removed.ok).toBe(true); expect(removed.ok).toBe(true);
const missing = await cmdCasGet(storageRoot, "after-rm", hash); const missing = await cmdCasGet(storageRoot, hash);
expect(missing.ok).toBe(false); expect(missing.ok).toBe(false);
}); });
@@ -3,9 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow"; import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
import { cmdAdd } from "../src/cmd-add.js"; import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
import { cmdFork } from "../src/cmd-fork.js"; import { cmdAdd } from "../src/commands/workflow/index.js";
import { cmdRun } from "../src/cmd-run.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
@@ -10,7 +10,7 @@ import {
getGlobalCasDir, getGlobalCasDir,
putContentMerkleNode, putContentMerkleNode,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { cmdThreadRemove } from "../src/cmd-thread.js"; import { cmdThreadRemove } from "../src/commands/thread/index.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
+1 -1
View File
@@ -5,7 +5,7 @@ import {
formatSkillIndex, formatSkillIndex,
formatSkillTopic, formatSkillTopic,
getSkillTopics, getSkillTopics,
} from "../src/cmd-help.js"; } from "../src/skill.js";
const STORAGE_ROOT = "/tmp/help-test-storage"; const STORAGE_ROOT = "/tmp/help-test-storage";
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { runCli } from "../src/cli-dispatch.js"; import { runCli } from "../src/cli-dispatch.js";
import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js"; import { cmdInitTemplate, cmdInitWorkspace } from "../src/commands/init/index.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
describe("init template", () => { describe("init template", () => {
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { formatCliUsage, runCli } from "../src/cli-dispatch.js"; import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
import { cmdInitWorkspace } from "../src/cmd-init.js"; import { cmdInitWorkspace } from "../src/commands/init/index.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
describe("init workspace", () => { describe("init workspace", () => {
+1 -1
View File
@@ -13,7 +13,7 @@ import {
LIVE_CONTENT_MAX_LINES, LIVE_CONTENT_MAX_LINES,
type LiveRoleRow, type LiveRoleRow,
renderLiveRoleStepLines, renderLiveRoleStepLines,
} from "../src/cmd-live.js"; } from "../src/commands/thread/index.js";
import { parseLiveArgv } from "../src/live-argv.js"; import { parseLiveArgv } from "../src/live-argv.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
@@ -5,15 +5,18 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { getGlobalCasDir } from "@uncaged/workflow"; import { getGlobalCasDir } from "@uncaged/workflow";
import { cmdAdd } from "../src/cmd-add.js"; import { cmdCasPut } from "../src/commands/cas/index.js";
import { cmdCasPut } from "../src/cmd-cas.js"; import {
import { cmdKill } from "../src/cmd-kill.js"; cmdKill,
import { cmdPause } from "../src/cmd-pause.js"; cmdPause,
import { cmdPs } from "../src/cmd-ps.js"; cmdPs,
import { cmdResume } from "../src/cmd-resume.js"; cmdResume,
import { cmdRun } from "../src/cmd-run.js"; cmdRun,
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js"; cmdThreadRemove,
import { cmdThreads } from "../src/cmd-threads.js"; cmdThreadShow,
cmdThreads,
} from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js"; import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
@@ -233,7 +236,7 @@ describe("cli thread commands", () => {
const runningPath = join(dirname(dataPath), `${threadId}.running`); const runningPath = join(dirname(dataPath), `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 120); await waitUntilRunningFileAbsent(runningPath, 120);
const put = await cmdCasPut(storageRoot, threadId, "keep-after-thread-rm"); const put = await cmdCasPut(storageRoot, "keep-after-thread-rm");
expect(put.ok).toBe(true); expect(put.ok).toBe(true);
if (!put.ok) { if (!put.ok) {
return; return;
+2 -9
View File
@@ -1,16 +1,9 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow";
async function pathExists(path: string): Promise<boolean> { import { pathExists } from "./fs-utils.js";
try {
await stat(path);
return true;
} catch {
return false;
}
}
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string }; export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
+17
View File
@@ -0,0 +1,17 @@
export function shouldUseColor(): boolean {
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
}
export function highlightLiveRole(name: string): string {
if (!shouldUseColor()) {
return name;
}
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
}
export function dimGreyLine(line: string): string {
if (!shouldUseColor()) {
return line;
}
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
}
@@ -0,0 +1,19 @@
export type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
export type CommandEntry = {
handler: DispatchFn;
args: string;
description: string;
};
export type CommandGroup = {
name: string;
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
};
export type DispatchGroupFn = (
tableName: string,
table: Record<string, CommandEntry>,
storageRoot: string,
argv: string[],
) => Promise<number> | null;
+42 -669
View File
@@ -1,613 +1,33 @@
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js"; import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js"; import { getCommandRegistry } from "./cli-registry.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js"; import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { cmdFork, parseForkArgv } from "./cmd-fork.js"; import { createCasDispatcher, dispatchGc } from "./commands/cas/index.js";
import { cmdGc } from "./cmd-gc.js"; import { createInitDispatcher } from "./commands/init/index.js";
import { import {
formatSkillDoc, createThreadDispatcher,
formatSkillIndex, dispatchFork,
formatSkillTopic, dispatchKill,
getSkillTopics, dispatchLive,
} from "./cmd-help.js"; dispatchPause,
import { cmdHistory } from "./cmd-history.js"; dispatchPs,
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js"; dispatchResume,
import { cmdKill } from "./cmd-kill.js"; dispatchRun,
import { cmdList, formatListLines } from "./cmd-list.js"; dispatchThreadList,
import { cmdLive } from "./cmd-live.js"; } from "./commands/thread/index.js";
import { cmdPause } from "./cmd-pause.js"; import {
import { cmdPs } from "./cmd-ps.js"; createWorkflowDispatcher,
import { cmdRemove } from "./cmd-remove.js"; dispatchAdd,
import { cmdResume } from "./cmd-resume.js"; dispatchHistory,
import { cmdRollback } from "./cmd-rollback.js"; dispatchList,
import { cmdRun } from "./cmd-run.js"; dispatchRemove,
import { cmdShow, formatShowYaml } from "./cmd-show.js"; dispatchRollback,
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js"; dispatchShow,
import { cmdThreads } from "./cmd-threads.js"; } from "./commands/workflow/index.js";
import { parseLiveArgv } from "./live-argv.js"; import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
import { parseRunArgv } from "./run-argv.js";
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>; export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
export { getCommandRegistry } from "./cli-registry.js";
type CommandEntry = {
handler: DispatchFn;
args: string;
description: string;
};
type CommandGroup = {
name: string;
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
};
// ── Individual dispatch functions ──────────────────────────────────────
async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: init workspace requires <name>`);
return 1;
}
const result = await cmdInitWorkspace(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
return 0;
}
async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: init template requires <name>`);
return 1;
}
const result = await cmdInitTemplate(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized template at ${result.value.templatePath}`);
return 0;
}
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseAddArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdAdd(storageRoot, parsed.value);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const w of result.value.warnings) {
printCliWarn(w);
}
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
return 0;
}
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`);
return 1;
}
const result = await cmdList(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of formatListLines(result.value)) {
printCliLine(line);
}
return 0;
}
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: show requires <name>`);
return 1;
}
const result = await cmdShow(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(formatShowYaml(name, result.value));
return 0;
}
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: remove requires <name>`);
return 1;
}
const result = await cmdRemove(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed workflow "${name}" from registry`);
return 0;
}
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseRunArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdRun(
storageRoot,
parsed.value.name,
parsed.value.prompt,
parsed.value.maxRounds,
);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value.threadId);
return 0;
}
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`);
return 1;
}
for (const line of await cmdPs(storageRoot)) {
printCliLine(line);
}
return 0;
}
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: kill requires <thread-id>`);
return 1;
}
const result = await cmdKill(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`kill sent for thread ${threadId}`);
return 0;
}
async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseLiveArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
return cmdLive(storageRoot, parsed.value);
}
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: history requires <name>`);
return 1;
}
const result = await cmdHistory(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of result.value) {
printCliLine(line);
}
return 0;
}
async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: rollback requires <name> [hash]`);
return 1;
}
const hashArg = argv[1];
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`rolled back workflow "${name}"`);
return 0;
}
async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: pause requires <thread-id>`);
return 1;
}
const result = await cmdPause(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`pause sent for thread ${threadId}`);
return 0;
}
async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: resume requires <thread-id>`);
return 1;
}
const result = await cmdResume(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`resume sent for thread ${threadId}`);
return 0;
}
async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
const result = await cmdThreads(storageRoot, argv);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of result.value) {
printCliLine(line);
}
return 0;
}
async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: thread show requires <id>`);
return 1;
}
const result = await cmdThreadShow(storageRoot, id);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: thread rm requires <id>`);
return 1;
}
const result = await cmdThreadRemove(storageRoot, id);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed thread ${id}`);
return 0;
}
async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`);
return 1;
}
const result = await cmdGc(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
const stats = result.value;
printCliLine(
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
);
return 0;
}
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseForkArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value.threadId);
return 0;
}
// ── CAS subcommand table ───────────────────────────────────────────────
async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas get requires <thread-id> <hash>`);
return 1;
}
const result = await cmdCasGet(storageRoot, threadId, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const content = rest[1];
if (threadId === undefined || content === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas put requires <thread-id> <content>`);
return 1;
}
const result = await cmdCasPut(storageRoot, threadId, content);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
if (threadId === undefined || rest.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: cas list requires <thread-id>`);
return 1;
}
const result = await cmdCasList(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const hash of result.value) {
printCliLine(hash);
}
return 0;
}
async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas rm requires <thread-id> <hash>`);
return 1;
}
const result = await cmdCasRm(storageRoot, threadId, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed cas entry ${hash}`);
return 0;
}
// ── Subcommand tables with metadata ────────────────────────────────────
const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
add: {
handler: dispatchAdd,
args: "<name> <file.esm.js> [--types <path>]",
description: "Register a workflow bundle in the registry",
},
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
show: {
handler: dispatchShow,
args: "<name>",
description: "Show details of a registered workflow",
},
rm: {
handler: dispatchRemove,
args: "<name>",
description: "Remove a workflow from the registry",
},
history: {
handler: dispatchHistory,
args: "<name>",
description: "Show version history of a workflow",
},
rollback: {
handler: dispatchRollback,
args: "<name> [hash]",
description: "Rollback a workflow to a previous version",
},
};
const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
run: {
handler: dispatchRun,
args: "<name> [--prompt <text>] [--max-rounds N]",
description: "Start a new thread executing a workflow",
},
list: {
handler: dispatchThreadList,
args: "[name]",
description: "List threads, optionally filtered by workflow name",
},
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
fork: {
handler: dispatchFork,
args: "<thread-id> [--from-role <role>]",
description: "Fork a thread, optionally from a specific role",
},
ps: { handler: dispatchPs, args: "", description: "List running threads" },
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
live: {
handler: dispatchLive,
args: "<thread-id> | --latest [--debug] [--role <name>]",
description: "Attach to a thread and stream output live",
},
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
};
const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
get: {
handler: dispatchCasGet,
args: "<thread-id> <hash>",
description: "Retrieve content by hash from a thread's CAS",
},
put: {
handler: dispatchCasPut,
args: "<thread-id> <content>",
description: "Store content in a thread's CAS, returns hash",
},
list: {
handler: dispatchCasList,
args: "<thread-id>",
description: "List all CAS entries for a thread",
},
rm: { handler: dispatchCasRm, args: "<thread-id> <hash>", description: "Remove a CAS entry" },
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
};
const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
workspace: {
handler: dispatchInitWorkspace,
args: "<name>",
description: "Initialize a new workflow workspace",
},
template: {
handler: dispatchInitTemplate,
args: "<name>",
description: "Initialize a new workflow template",
},
};
// ── Command registry ───────────────────────────────────────────────────
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
{
name: "workflow",
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "thread",
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "cas",
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "init",
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
];
}
// ── Auto-generated CLI usage ───────────────────────────────────────────
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
workflow: "Workflow registry:",
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
};
function formatUsageCommandLines(
rows: ReadonlyArray<{ prefix: string; description: string }>,
): string[] {
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
const gap = 2;
return rows.map((row) => {
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
return ` ${row.prefix}${pad}${row.description}`;
});
}
export function formatCliUsage(): string {
const groups = getCommandRegistry();
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
for (const group of groups) {
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
if (sectionTitle === undefined) {
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
}
lines.push(sectionTitle);
const rows = group.commands.map((cmd) => {
const args = cmd.args ? ` ${cmd.args}` : "";
return {
prefix: `${group.name} ${cmd.name}${args}`,
description: cmd.description,
};
});
lines.push(...formatUsageCommandLines(rows));
lines.push("");
}
lines.push("Shortcuts:");
lines.push(
...formatUsageCommandLines([
{ prefix: "run <name> [...]", description: "→ thread run" },
{ prefix: "live <id> [...]", description: "→ thread live" },
]),
);
lines.push("");
lines.push("Reference:");
const skillTopicNames = getSkillTopics()
.map((t) => t.name)
.join(", ");
lines.push(
...formatUsageCommandLines([
{
prefix: "skill [topic]",
description: `Agent-consumable docs (${skillTopicNames})`,
},
]),
);
lines.push("");
lines.push("Use <command> --help for subcommand details.");
lines.push("");
lines.push("Environment variables:");
lines.push(
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
);
lines.push(
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
);
return lines.join("\n");
}
function printDeprecation(oldCmd: string, newCmd: string): void {
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
}
// ── Group dispatchers ──────────────────────────────────────────────────
function dispatchGroup( function dispatchGroup(
tableName: string, tableName: string,
@@ -634,54 +54,20 @@ function dispatchGroup(
return entry.handler(storageRoot, argv.slice(1)); return entry.handler(storageRoot, argv.slice(1));
} }
async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> { function printDeprecation(oldCmd: string, newCmd: string): void {
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv); printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
return 1;
} }
async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> { export function formatCliUsage(): string {
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv); return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
if (result !== null) {
return result;
}
const sub = argv[0];
if (sub === "remove") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1));
}
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: ${sub}`);
return 1;
} }
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> { const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup, printDeprecation });
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv); const dispatchThread = createThreadDispatcher({ dispatchGroup });
if (result !== null) { const dispatchCas = createCasDispatcher({ dispatchGroup });
return result; const dispatchInit = createInitDispatcher({ dispatchGroup });
}
const sub = argv[0];
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: ${sub}`);
return 1;
}
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> { async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
return 1;
}
// ── Help ────────────────────────────────────────────────────────────────
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
const topic = argv[0];
if (topic === undefined) { if (topic === undefined) {
printCliLine(formatSkillIndex()); printCliLine(formatSkillIndex());
return 0; return 0;
@@ -695,44 +81,31 @@ async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<numb
return 0; return 0;
} }
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
return showSkillDocOrIndex(argv[0]);
}
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> { async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
// Legacy compat: help --skill [topic] → skill [topic] printCliWarn('⚠ "help" is deprecated, use "skill" instead');
const skillIdx = argv.indexOf("--skill"); const skillIdx = argv.indexOf("--skill");
if (skillIdx !== -1) { if (skillIdx !== -1) {
const topic = argv[skillIdx + 1]; return showSkillDocOrIndex(argv[skillIdx + 1]);
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
}
const doc = formatSkillTopic(topic);
if (doc === null) {
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
return 1;
}
printCliLine(doc);
return 0;
} }
printCliLine(formatCliUsage()); printCliLine(formatCliUsage());
return 0; return 0;
} }
// ── Top-level command table (Phase 3) ──────────────────────────────────
const COMMAND_TABLE: Record<string, DispatchFn> = { const COMMAND_TABLE: Record<string, DispatchFn> = {
// Grouped commands (primary)
workflow: dispatchWorkflow, workflow: dispatchWorkflow,
thread: dispatchThread, thread: dispatchThread,
cas: dispatchCas, cas: dispatchCas,
init: dispatchInit, init: dispatchInit,
help: dispatchHelp, help: dispatchHelp,
skill: dispatchSkill, skill: dispatchSkill,
// Top-level shortcuts (no deprecation)
run: dispatchRun, run: dispatchRun,
live: dispatchLive, live: dispatchLive,
}; };
// Deprecated flat commands that delegate to grouped commands
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = { const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
add: { newCmd: "workflow add", handler: dispatchAdd }, add: { newCmd: "workflow add", handler: dispatchAdd },
list: { newCmd: "workflow list", handler: dispatchList }, list: { newCmd: "workflow list", handler: dispatchList },
+45
View File
@@ -0,0 +1,45 @@
import type { CommandGroup } from "./cli-command-types.js";
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
{
name: "workflow",
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "thread",
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "cas",
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "init",
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
];
}
setCommandGroupsForUsage(getCommandRegistry());
@@ -0,0 +1,14 @@
import type { CommandGroup } from "./cli-command-types.js";
let commandGroupsForUsage: ReadonlyArray<CommandGroup> | null = null;
export function setCommandGroupsForUsage(groups: ReadonlyArray<CommandGroup>): void {
commandGroupsForUsage = groups;
}
export function getCommandGroupsForUsage(): ReadonlyArray<CommandGroup> {
if (commandGroupsForUsage === null) {
throw new Error("BUG: command groups for usage not initialized");
}
return commandGroupsForUsage;
}
+81
View File
@@ -0,0 +1,81 @@
import type { CommandGroup } from "./cli-command-types.js";
/** Keep aligned with `getSkillTopics()` in skill.ts (names only, for error usage lines). */
export const USAGE_SKILL_TOPIC_ROWS: ReadonlyArray<{ name: string }> = [
{ name: "cli" },
{ name: "develop" },
{ name: "author" },
];
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
workflow: "Workflow registry:",
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
};
export function formatUsageCommandLines(
rows: ReadonlyArray<{ prefix: string; description: string }>,
): string[] {
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
const gap = 2;
return rows.map((row) => {
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
return ` ${row.prefix}${pad}${row.description}`;
});
}
export function formatCliUsage(
groups: ReadonlyArray<CommandGroup>,
skillTopics: ReadonlyArray<{ name: string }>,
): string {
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
for (const group of groups) {
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
if (sectionTitle === undefined) {
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
}
lines.push(sectionTitle);
const rows = group.commands.map((cmd) => {
const args = cmd.args ? ` ${cmd.args}` : "";
return {
prefix: `${group.name} ${cmd.name}${args}`,
description: cmd.description,
};
});
lines.push(...formatUsageCommandLines(rows));
lines.push("");
}
lines.push("Shortcuts:");
lines.push(
...formatUsageCommandLines([
{ prefix: "run <name> [...]", description: "→ thread run" },
{ prefix: "live <id> [...]", description: "→ thread live" },
]),
);
lines.push("");
lines.push("Reference:");
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
lines.push(
...formatUsageCommandLines([
{
prefix: "skill [topic]",
description: `Agent-consumable docs (${skillTopicNames})`,
},
]),
);
lines.push("");
lines.push("Use <command> --help for subcommand details.");
lines.push("");
lines.push("Environment variables:");
lines.push(
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
);
lines.push(
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
);
return lines.join("\n");
}
-43
View File
@@ -1,43 +0,0 @@
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasGet(
storageRoot: string,
_threadId: string,
hash: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const content = await cas.get(hash);
if (content === null) {
return err(`cas entry not found: ${hash}`);
}
return ok(content);
}
export async function cmdCasPut(
storageRoot: string,
_threadId: string,
content: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const hash = await cas.put(content);
return ok(hash);
}
export async function cmdCasList(
storageRoot: string,
_threadId: string,
): Promise<Result<string[], string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const hashes = await cas.list();
return ok(hashes);
}
export async function cmdCasRm(
storageRoot: string,
_threadId: string,
hash: string,
): Promise<Result<void, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
await cas.delete(hash);
return ok(undefined);
}
-43
View File
@@ -1,43 +0,0 @@
import { join } from "node:path";
import { err, type Result } from "@uncaged/workflow";
import { readTextFileIfExists } from "./fs-utils.js";
import {
resolveRunningHashForThread,
sendWorkerTcpCommand,
type WorkerCtl,
} from "./worker-spawn.js";
export async function cmdKill(
storageRoot: string,
threadId: string,
): Promise<Result<void, string>> {
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
if (!hashResult.ok) {
return hashResult;
}
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
const ctlText = await readTextFileIfExists(ctlPath);
if (ctlText === null) {
return err(`worker control file missing for bundle hash ${hashResult.value}`);
}
let ctl: WorkerCtl;
try {
ctl = JSON.parse(ctlText) as WorkerCtl;
} catch {
return err(`corrupt worker control file: ${ctlPath}`);
}
if (typeof ctl.port !== "number" || ctl.port <= 0) {
return err(`invalid worker control file: ${ctlPath}`);
}
return await sendWorkerTcpCommand(
ctl.port,
{ type: "kill", threadId },
{ awaitResponseLine: true },
);
}
-43
View File
@@ -1,43 +0,0 @@
import { join } from "node:path";
import { err, type Result } from "@uncaged/workflow";
import { readTextFileIfExists } from "./fs-utils.js";
import {
resolveRunningHashForThread,
sendWorkerTcpCommand,
type WorkerCtl,
} from "./worker-spawn.js";
export async function cmdPause(
storageRoot: string,
threadId: string,
): Promise<Result<void, string>> {
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
if (!hashResult.ok) {
return hashResult;
}
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
const ctlText = await readTextFileIfExists(ctlPath);
if (ctlText === null) {
return err(`worker control file missing for bundle hash ${hashResult.value}`);
}
let ctl: WorkerCtl;
try {
ctl = JSON.parse(ctlText) as WorkerCtl;
} catch {
return err(`corrupt worker control file: ${ctlPath}`);
}
if (typeof ctl.port !== "number" || ctl.port <= 0) {
return err(`invalid worker control file: ${ctlPath}`);
}
return await sendWorkerTcpCommand(
ctl.port,
{ type: "pause", threadId },
{ awaitResponseLine: true },
);
}
-43
View File
@@ -1,43 +0,0 @@
import { join } from "node:path";
import { err, type Result } from "@uncaged/workflow";
import { readTextFileIfExists } from "./fs-utils.js";
import {
resolveRunningHashForThread,
sendWorkerTcpCommand,
type WorkerCtl,
} from "./worker-spawn.js";
export async function cmdResume(
storageRoot: string,
threadId: string,
): Promise<Result<void, string>> {
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
if (!hashResult.ok) {
return hashResult;
}
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
const ctlText = await readTextFileIfExists(ctlPath);
if (ctlText === null) {
return err(`worker control file missing for bundle hash ${hashResult.value}`);
}
let ctl: WorkerCtl;
try {
ctl = JSON.parse(ctlText) as WorkerCtl;
} catch {
return err(`corrupt worker control file: ${ctlPath}`);
}
if (typeof ctl.port !== "number" || ctl.port <= 0) {
return err(`invalid worker control file: ${ctlPath}`);
}
return await sendWorkerTcpCommand(
ctl.port,
{ type: "resume", threadId },
{ awaitResponseLine: true },
);
}
@@ -0,0 +1,125 @@
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
import { cmdGc } from "./gc.js";
import { cmdCasGet } from "./get.js";
import { cmdCasList } from "./list.js";
import { cmdCasPut } from "./put.js";
import { cmdCasRm } from "./rm.js";
import type { CasDispatchDeps } from "./types.js";
function usageText(): string {
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
}
export async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${usageText()}\n\nerror: gc takes no arguments`);
return 1;
}
const result = await cmdGc(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
const stats = result.value;
printCliLine(
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
);
return 0;
}
export async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
const hash = rest[0];
if (hash === undefined || rest.length > 1) {
printCliError(`${usageText()}\n\nerror: cas get requires <hash>`);
return 1;
}
const result = await cmdCasGet(storageRoot, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
export async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
const content = rest[0];
if (content === undefined || rest.length > 1) {
printCliError(`${usageText()}\n\nerror: cas put requires <content>`);
return 1;
}
const result = await cmdCasPut(storageRoot, content);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
export async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
if (rest.length > 0) {
printCliError(`${usageText()}\n\nerror: cas list takes no arguments`);
return 1;
}
const result = await cmdCasList(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const hash of result.value) {
printCliLine(hash);
}
return 0;
}
export async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
const hash = rest[0];
if (hash === undefined || rest.length > 1) {
printCliError(`${usageText()}\n\nerror: cas rm requires <hash>`);
return 1;
}
const result = await cmdCasRm(storageRoot, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed cas entry ${hash}`);
return 0;
}
export const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
get: {
handler: dispatchCasGet,
args: "<hash>",
description: "Retrieve content by hash from CAS",
},
put: {
handler: dispatchCasPut,
args: "<content>",
description: "Store content in CAS, prints hash",
},
list: {
handler: dispatchCasList,
args: "",
description: "List all hashes in CAS",
},
rm: { handler: dispatchCasRm, args: "<hash>", description: "Remove a CAS entry by hash" },
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
};
export function createCasDispatcher(deps: CasDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${usageText()}\n\nerror: unknown cas subcommand: ${sub}`);
return 1;
};
}
@@ -0,0 +1,13 @@
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasGet(
storageRoot: string,
hash: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const content = await cas.get(hash);
if (content === null) {
return err(`cas entry not found: ${hash}`);
}
return ok(content);
}
@@ -0,0 +1,14 @@
export {
CAS_SUBCOMMAND_TABLE,
createCasDispatcher,
dispatchCasGet,
dispatchCasList,
dispatchCasPut,
dispatchCasRm,
dispatchGc,
} from "./dispatch.js";
export { cmdGc } from "./gc.js";
export { cmdCasGet } from "./get.js";
export { cmdCasList } from "./list.js";
export { cmdCasPut } from "./put.js";
export { cmdCasRm } from "./rm.js";
@@ -0,0 +1,7 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const hashes = await cas.list();
return ok(hashes);
}
@@ -0,0 +1,10 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasPut(
storageRoot: string,
content: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const hash = await cas.put(content);
return ok(hash);
}
@@ -0,0 +1,7 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
await cas.delete(hash);
return ok(undefined);
}
@@ -0,0 +1,5 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type CasDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -0,0 +1,67 @@
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
import { cmdInitTemplate } from "./template.js";
import type { InitDispatchDeps } from "./types.js";
import { cmdInitWorkspace } from "./workspace.js";
function usageText(): string {
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
}
export async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: init workspace requires <name>`);
return 1;
}
const result = await cmdInitWorkspace(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
return 0;
}
export async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: init template requires <name>`);
return 1;
}
const result = await cmdInitTemplate(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized template at ${result.value.templatePath}`);
return 0;
}
export const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
workspace: {
handler: dispatchInitWorkspace,
args: "<name>",
description: "Initialize a new workflow workspace",
},
template: {
handler: dispatchInitTemplate,
args: "<name>",
description: "Initialize a new workflow template",
},
};
export function createInitDispatcher(deps: InitDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${usageText()}\n\nerror: unknown init subcommand: ${sub}`);
return 1;
};
}
@@ -0,0 +1,9 @@
export {
createInitDispatcher,
dispatchInitTemplate,
dispatchInitWorkspace,
INIT_SUBCOMMAND_TABLE,
} from "./dispatch.js";
export { cmdInitTemplate } from "./template.js";
export type { CmdInitTemplateSuccess, CmdInitWorkspaceSuccess } from "./types.js";
export { cmdInitWorkspace } from "./workspace.js";
@@ -0,0 +1,106 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "../../fs-utils.js";
import {
templateIndexTs,
templateModeratorTs,
templatePackageJson,
templateRolesTs,
templateTsconfigJson,
} from "./templates.js";
import type { CmdInitTemplateSuccess } from "./types.js";
function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
return Array.isArray(workspaces) && workspaces.includes("templates/*");
}
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
const pkgPath = join(dir, "package.json");
if (!(await pathExists(pkgPath))) {
return null;
}
let raw: string;
try {
raw = await readFile(pkgPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
return null;
}
return (parsed as { workspaces: unknown }).workspaces;
}
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
let dir = resolve(startDir);
for (;;) {
const workspaces = await readPackageJsonWorkspaces(dir);
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
return ok(dir);
}
const parent = dirname(dir);
if (parent === dir) {
return err(
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
);
}
dir = parent;
}
}
export async function cmdInitTemplate(
startDir: string,
templateName: string,
): Promise<Result<CmdInitTemplateSuccess, string>> {
const validated = validateWorkspaceSegment(templateName);
if (!validated.ok) {
return validated;
}
const rootResult = await findWorkflowWorkspaceRoot(startDir);
if (!rootResult.ok) {
return rootResult;
}
const workspaceRoot = rootResult.value;
const templateDir = join(workspaceRoot, "templates", templateName);
if (await pathExists(templateDir)) {
return err(`template already exists: ${templateDir}`);
}
await mkdir(join(templateDir, "src"), { recursive: true });
await Promise.all([
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
]);
return ok({ templatePath: templateDir });
}
@@ -0,0 +1,101 @@
export function templatePackageJson(templateName: string): string {
return `${JSON.stringify(
{
name: `template-${templateName}`,
version: "0.0.0",
private: true,
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
zod: "^4.0.0",
},
},
null,
2,
)}\n`;
}
export function templateTsconfigJson(): string {
return `${JSON.stringify(
{
extends: "../../tsconfig.json",
compilerOptions: {
rootDir: "src",
outDir: "dist",
},
include: ["src/**/*.ts"],
},
null,
2,
)}\n`;
}
export function templateRolesTs(): string {
return `import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const HELLO_TEMPLATE_DESCRIPTION =
"Minimal starter template: one greeter role, then END.";
export type HelloTemplateMeta = {
greeter: {
message: string;
};
};
const greeterMetaSchema = z.object({
message: z.string(),
});
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
extractPrompt: "Extract the assistant's greeting as message.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
export function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
import type { HelloTemplateMeta } from "./roles.js";
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
ctx: ModeratorContext<HelloTemplateMeta>,
) => {
if (ctx.steps.length === 0) {
return "greeter";
}
return END;
};
`;
}
export function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow";
import { helloTemplateModerator } from "./moderator.js";
import {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export { helloTemplateModerator } from "./moderator.js";
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
description: HELLO_TEMPLATE_DESCRIPTION,
roles: {
greeter: greeterRole,
},
moderator: helloTemplateModerator,
};
`;
}
@@ -0,0 +1,13 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type CmdInitTemplateSuccess = {
templatePath: string;
};
export type CmdInitWorkspaceSuccess = {
rootPath: string;
};
export type InitDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -1,17 +1,10 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "./fs-utils.js"; import { pathExists } from "../../fs-utils.js";
import type { CmdInitWorkspaceSuccess } from "./types.js";
export type CmdInitWorkspaceSuccess = {
rootPath: string;
};
export type CmdInitTemplateSuccess = {
templatePath: string;
};
function validateWorkspaceSegment(name: string): Result<void, string> { function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) { if (name.length === 0) {
@@ -233,183 +226,3 @@ export async function cmdInitWorkspace(
return ok({ rootPath }); return ok({ rootPath });
} }
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
return Array.isArray(workspaces) && workspaces.includes("templates/*");
}
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
const pkgPath = join(dir, "package.json");
if (!(await pathExists(pkgPath))) {
return null;
}
let raw: string;
try {
raw = await readFile(pkgPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
return null;
}
return (parsed as { workspaces: unknown }).workspaces;
}
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
let dir = resolve(startDir);
for (;;) {
const workspaces = await readPackageJsonWorkspaces(dir);
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
return ok(dir);
}
const parent = dirname(dir);
if (parent === dir) {
return err(
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
);
}
dir = parent;
}
}
function templatePackageJson(templateName: string): string {
return `${JSON.stringify(
{
name: `template-${templateName}`,
version: "0.0.0",
private: true,
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
zod: "^4.0.0",
},
},
null,
2,
)}\n`;
}
function templateTsconfigJson(): string {
return `${JSON.stringify(
{
extends: "../../tsconfig.json",
compilerOptions: {
rootDir: "src",
outDir: "dist",
},
include: ["src/**/*.ts"],
},
null,
2,
)}\n`;
}
function templateRolesTs(): string {
return `import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const HELLO_TEMPLATE_DESCRIPTION =
"Minimal starter template: one greeter role, then END.";
export type HelloTemplateMeta = {
greeter: {
message: string;
};
};
const greeterMetaSchema = z.object({
message: z.string(),
});
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
extractPrompt: "Extract the assistant's greeting as message.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
import type { HelloTemplateMeta } from "./roles.js";
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
ctx: ModeratorContext<HelloTemplateMeta>,
) => {
if (ctx.steps.length === 0) {
return "greeter";
}
return END;
};
`;
}
function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow";
import { helloTemplateModerator } from "./moderator.js";
import {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export { helloTemplateModerator } from "./moderator.js";
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
description: HELLO_TEMPLATE_DESCRIPTION,
roles: {
greeter: greeterRole,
},
moderator: helloTemplateModerator,
};
`;
}
export async function cmdInitTemplate(
startDir: string,
templateName: string,
): Promise<Result<CmdInitTemplateSuccess, string>> {
const validated = validateWorkspaceSegment(templateName);
if (!validated.ok) {
return validated;
}
const rootResult = await findWorkflowWorkspaceRoot(startDir);
if (!rootResult.ok) {
return rootResult;
}
const workspaceRoot = rootResult.value;
const templateDir = join(workspaceRoot, "templates", templateName);
if (await pathExists(templateDir)) {
return err(`template already exists: ${templateDir}`);
}
await mkdir(join(templateDir, "src"), { recursive: true });
await Promise.all([
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
]);
return ok({ templatePath: templateDir });
}
@@ -0,0 +1,52 @@
import type { Result } from "@uncaged/workflow";
import {
readWorkerCtl,
resolveRunningHashForThread,
sendWorkerTcpCommand,
} from "../../worker-spawn.js";
type ThreadControlAction = "kill" | "pause" | "resume";
async function cmdThreadControl(
storageRoot: string,
threadId: string,
action: ThreadControlAction,
): Promise<Result<void, string>> {
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
if (!hashResult.ok) {
return hashResult;
}
const ctlResult = await readWorkerCtl(storageRoot, hashResult.value);
if (!ctlResult.ok) {
return ctlResult;
}
return await sendWorkerTcpCommand(
ctlResult.value.port,
{ type: action, threadId },
{ awaitResponseLine: true },
);
}
export async function cmdKill(
storageRoot: string,
threadId: string,
): Promise<Result<void, string>> {
return cmdThreadControl(storageRoot, threadId, "kill");
}
export async function cmdPause(
storageRoot: string,
threadId: string,
): Promise<Result<void, string>> {
return cmdThreadControl(storageRoot, threadId, "pause");
}
export async function cmdResume(
storageRoot: string,
threadId: string,
): Promise<Result<void, string>> {
return cmdThreadControl(storageRoot, threadId, "resume");
}
@@ -0,0 +1,206 @@
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
import { parseLiveArgv } from "../../live-argv.js";
import { parseRunArgv } from "../../run-argv.js";
import { cmdKill, cmdPause, cmdResume } from "./control.js";
import { cmdFork } from "./fork.js";
import { parseForkArgv } from "./fork-argv.js";
import { cmdThreads } from "./list.js";
import { cmdLive } from "./live.js";
import { cmdPs } from "./ps.js";
import { cmdThreadRemove } from "./rm.js";
import { cmdRun } from "./run.js";
import { cmdThreadShow } from "./show.js";
import type { ThreadDispatchDeps } from "./types.js";
function usageText(): string {
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
}
export async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseRunArgv(argv);
if (!parsed.ok) {
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdRun(
storageRoot,
parsed.value.name,
parsed.value.prompt,
parsed.value.maxRounds,
);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value.threadId);
return 0;
}
export async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${usageText()}\n\nerror: ps takes no arguments`);
return 1;
}
for (const line of await cmdPs(storageRoot)) {
printCliLine(line);
}
return 0;
}
export async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: kill requires <thread-id>`);
return 1;
}
const result = await cmdKill(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`kill sent for thread ${threadId}`);
return 0;
}
export async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseLiveArgv(argv);
if (!parsed.ok) {
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
return 1;
}
return cmdLive(storageRoot, parsed.value);
}
export async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: pause requires <thread-id>`);
return 1;
}
const result = await cmdPause(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`pause sent for thread ${threadId}`);
return 0;
}
export async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: resume requires <thread-id>`);
return 1;
}
const result = await cmdResume(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`resume sent for thread ${threadId}`);
return 0;
}
export async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
const result = await cmdThreads(storageRoot, argv);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of result.value) {
printCliLine(line);
}
return 0;
}
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: thread show requires <id>`);
return 1;
}
const result = await cmdThreadShow(storageRoot, id);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
export async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: thread rm requires <id>`);
return 1;
}
const result = await cmdThreadRemove(storageRoot, id);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed thread ${id}`);
return 0;
}
export async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseForkArgv(argv);
if (!parsed.ok) {
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value.threadId);
return 0;
}
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
run: {
handler: dispatchRun,
args: "<name> [--prompt <text>] [--max-rounds N]",
description: "Start a new thread executing a workflow",
},
list: {
handler: dispatchThreadList,
args: "[name]",
description: "List threads, optionally filtered by workflow name",
},
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
fork: {
handler: dispatchFork,
args: "<thread-id> [--from-role <role>]",
description: "Fork a thread, optionally from a specific role",
},
ps: { handler: dispatchPs, args: "", description: "List running threads" },
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
live: {
handler: dispatchLive,
args: "<thread-id> | --latest [--debug] [--role <name>]",
description: "Attach to a thread and stream output live",
},
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
};
export function createThreadDispatcher(deps: ThreadDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${usageText()}\n\nerror: unknown thread subcommand: ${sub}`);
return 1;
};
}
@@ -0,0 +1,28 @@
import { err, ok, type Result } from "@uncaged/workflow";
import type { ParsedForkArgv } from "./types.js";
export function parseForkArgv(argv: string[]): Result<ParsedForkArgv, string> {
if (argv.length === 0) {
return err("fork requires <thread-id>");
}
const threadId = argv[0];
if (threadId === undefined || threadId === "") {
return err("fork requires <thread-id>");
}
let fromRole: string | null = null;
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
if (a === "--from-role") {
const r = argv[i + 1];
if (r === undefined || r === "") {
return err("--from-role requires a role name");
}
fromRole = r;
i++;
continue;
}
return err(`unexpected argument: ${a}`);
}
return ok({ threadId, fromRole });
}
@@ -2,36 +2,9 @@ import { join } from "node:path";
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow"; import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
import { pathExists, readTextFileIfExists } from "./fs-utils.js"; import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
import { resolveThreadDataPath } from "./thread-scan.js"; import { resolveThreadDataPath } from "../../thread-scan.js";
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js"; import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
export function parseForkArgv(
argv: string[],
): Result<{ threadId: string; fromRole: string | null }, string> {
if (argv.length === 0) {
return err("fork requires <thread-id>");
}
const threadId = argv[0];
if (threadId === undefined || threadId === "") {
return err("fork requires <thread-id>");
}
let fromRole: string | null = null;
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
if (a === "--from-role") {
const r = argv[i + 1];
if (r === undefined || r === "") {
return err("--from-role requires a role name");
}
fromRole = r;
i++;
continue;
}
return err(`unexpected argument: ${a}`);
}
return ok({ threadId, fromRole });
}
export async function cmdFork( export async function cmdFork(
storageRoot: string, storageRoot: string,
@@ -0,0 +1,30 @@
export { cmdKill, cmdPause, cmdResume } from "./control.js";
export {
createThreadDispatcher,
dispatchFork,
dispatchKill,
dispatchLive,
dispatchPause,
dispatchPs,
dispatchResume,
dispatchRun,
dispatchThreadList,
dispatchThreadRm,
dispatchThreadShow,
THREAD_SUBCOMMAND_TABLE,
} from "./dispatch.js";
export { cmdFork } from "./fork.js";
export { parseForkArgv } from "./fork-argv.js";
export { cmdThreads } from "./list.js";
export {
cmdLive,
formatLiveDebugLine,
formatLiveTimeLabel,
LIVE_CONTENT_MAX_LINES,
renderLiveRoleStepLines,
} from "./live.js";
export { cmdPs } from "./ps.js";
export { cmdThreadRemove } from "./rm.js";
export { cmdRun } from "./run.js";
export { cmdThreadShow } from "./show.js";
export type { LiveRoleRow } from "./types.js";
@@ -1,7 +1,7 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow";
import { listHistoricalThreads } from "./thread-scan.js"; import { listHistoricalThreads } from "../../thread-scan.js";
import { validateCliWorkflowName } from "./workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
export async function cmdThreads( export async function cmdThreads(
storageRoot: string, storageRoot: string,
@@ -12,20 +12,15 @@ import {
type WorkflowCompletion, type WorkflowCompletion,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { printCliError, printCliLine } from "./cli-output.js"; import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
import { pathExists } from "./fs-utils.js"; import { printCliError, printCliLine } from "../../cli-output.js";
import type { ParsedLiveArgv } from "./live-argv.js"; import { pathExists } from "../../fs-utils.js";
import { findLatestThreadDataPath, resolveThreadDataPath } from "./thread-scan.js"; import type { ParsedLiveArgv } from "../../live-argv.js";
import { findLatestThreadDataPath, resolveThreadDataPath } from "../../thread-scan.js";
import type { LiveRoleRow } from "./types.js";
export const LIVE_CONTENT_MAX_LINES = 10; export const LIVE_CONTENT_MAX_LINES = 10;
export type LiveRoleRow = {
role: string;
content: string;
meta: Record<string, unknown>;
timestamp: number;
};
export function formatLiveTimeLabel(timestampMs: number): string { export function formatLiveTimeLabel(timestampMs: number): string {
const d = new Date(timestampMs); const d = new Date(timestampMs);
const hh = String(d.getHours()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0");
@@ -34,24 +29,6 @@ export function formatLiveTimeLabel(timestampMs: number): string {
return `${hh}:${mm}:${ss}`; return `${hh}:${mm}:${ss}`;
} }
function shouldUseColor(): boolean {
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
}
function highlightLiveRole(name: string): string {
if (!shouldUseColor()) {
return name;
}
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
}
function dimGreyLine(line: string): string {
if (!shouldUseColor()) {
return line;
}
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
}
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string { export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`; const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
return dimGreyLine(label); return dimGreyLine(label);
@@ -1,4 +1,4 @@
import { listRunningThreads } from "./thread-scan.js"; import { listRunningThreads } from "../../thread-scan.js";
export async function cmdPs(storageRoot: string): Promise<string[]> { export async function cmdPs(storageRoot: string): Promise<string[]> {
const rows = await listRunningThreads(storageRoot); const rows = await listRunningThreads(storageRoot);
@@ -3,23 +3,7 @@ import { dirname, join } from "node:path";
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow"; import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
import { readTextFileIfExists } from "./fs-utils.js"; import { resolveThreadDataPath } from "../../thread-scan.js";
import { resolveThreadDataPath } from "./thread-scan.js";
export async function cmdThreadShow(
storageRoot: string,
threadId: string,
): Promise<Result<string, string>> {
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
if (dataPath === null) {
return err(`thread not found: ${threadId}`);
}
const text = await readTextFileIfExists(dataPath);
if (text === null) {
return err(`thread data missing: ${threadId}`);
}
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
}
export async function cmdThreadRemove( export async function cmdThreadRemove(
storageRoot: string, storageRoot: string,
@@ -8,8 +8,8 @@ import {
type Result, type Result,
readWorkflowRegistry, readWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js"; import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
import { validateCliWorkflowName } from "./workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
export async function cmdRun( export async function cmdRun(
storageRoot: string, storageRoot: string,
@@ -0,0 +1,19 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { readTextFileIfExists } from "../../fs-utils.js";
import { resolveThreadDataPath } from "../../thread-scan.js";
export async function cmdThreadShow(
storageRoot: string,
threadId: string,
): Promise<Result<string, string>> {
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
if (dataPath === null) {
return err(`thread not found: ${threadId}`);
}
const text = await readTextFileIfExists(dataPath);
if (text === null) {
return err(`thread data missing: ${threadId}`);
}
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
}
@@ -0,0 +1,17 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type LiveRoleRow = {
role: string;
content: string;
meta: Record<string, unknown>;
timestamp: number;
};
export type ParsedForkArgv = {
threadId: string;
fromRole: string | null;
};
export type ThreadDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -0,0 +1,72 @@
import { err, ok, type Result } from "@uncaged/workflow";
import type { ParsedAddArgv } from "./types.js";
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
function tryParseAddLongFlag(argv: string[], index: number): Result<ParsedLongFlag | null, string> {
const tok = argv[index];
if (tok !== "--types") {
return ok(null);
}
const value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
return err("missing value for --types");
}
return ok({ advance: 2, kind: "types", value });
}
type PositionalSlots = {
name: string | undefined;
filePath: string | undefined;
};
function assignPositional(tok: string, slots: PositionalSlots): Result<void, string> {
if (slots.name === undefined) {
slots.name = tok;
return ok(undefined);
}
if (slots.filePath === undefined) {
slots.filePath = tok;
return ok(undefined);
}
return err("too many arguments");
}
export function parseAddArgv(argv: string[]): Result<ParsedAddArgv, string> {
const slots: PositionalSlots = { name: undefined, filePath: undefined };
let typesPath: string | null = null;
let i = 0;
while (i < argv.length) {
const flag = tryParseAddLongFlag(argv, i);
if (!flag.ok) {
return flag;
}
if (flag.value !== null) {
typesPath = flag.value.value;
i += flag.value.advance;
continue;
}
const tok = argv[i];
if (tok?.startsWith("--")) {
return err(`unknown add flag: ${tok}`);
}
if (tok === undefined) {
break;
}
const placed = assignPositional(tok, slots);
if (!placed.ok) {
return placed;
}
i += 1;
}
const { name, filePath } = slots;
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
return err("add requires <name> <file>");
}
return ok({ name, filePath, typesPath });
}
@@ -14,20 +14,10 @@ import {
writeWorkflowRegistry, writeWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { storeWorkflowBundleArtifacts } from "./bundle-store.js"; import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
import { validateCliWorkflowName } from "./workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
export type ParsedAddArgv = { import type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
name: string;
filePath: string;
/** Override path to `.d.ts` when adding a bundle. */
typesPath: string | null;
};
export type CmdAddSuccess = {
hash: string;
warnings: ReadonlyArray<string>;
};
function isEsmBundle(path: string): boolean { function isEsmBundle(path: string): boolean {
return path.endsWith(".esm.js"); return path.endsWith(".esm.js");
@@ -37,75 +27,6 @@ function defaultTypesPath(bundlePath: string): string {
return bundlePath.replace(/\.esm\.js$/i, ".d.ts"); return bundlePath.replace(/\.esm\.js$/i, ".d.ts");
} }
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
function tryParseAddLongFlag(argv: string[], index: number): Result<ParsedLongFlag | null, string> {
const tok = argv[index];
if (tok !== "--types") {
return ok(null);
}
const value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
return err("missing value for --types");
}
return ok({ advance: 2, kind: "types", value });
}
type PositionalSlots = {
name: string | undefined;
filePath: string | undefined;
};
function assignPositional(tok: string, slots: PositionalSlots): Result<void, string> {
if (slots.name === undefined) {
slots.name = tok;
return ok(undefined);
}
if (slots.filePath === undefined) {
slots.filePath = tok;
return ok(undefined);
}
return err("too many arguments");
}
export function parseAddArgv(argv: string[]): Result<ParsedAddArgv, string> {
const slots: PositionalSlots = { name: undefined, filePath: undefined };
let typesPath: string | null = null;
let i = 0;
while (i < argv.length) {
const flag = tryParseAddLongFlag(argv, i);
if (!flag.ok) {
return flag;
}
if (flag.value !== null) {
typesPath = flag.value.value;
i += flag.value.advance;
continue;
}
const tok = argv[i];
if (tok?.startsWith("--")) {
return err(`unknown add flag: ${tok}`);
}
if (tok === undefined) {
break;
}
const placed = assignPositional(tok, slots);
if (!placed.ok) {
return placed;
}
i += 1;
}
const { name, filePath } = slots;
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
return err("add requires <name> <file>");
}
return ok({ name, filePath, typesPath });
}
async function registerHash( async function registerHash(
storageRoot: string, storageRoot: string,
name: string, name: string,
@@ -0,0 +1,159 @@
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
import { cmdAdd, formatAddSuccess } from "./add.js";
import { parseAddArgv } from "./add-argv.js";
import { cmdHistory } from "./history.js";
import { cmdList, formatListLines } from "./list.js";
import { cmdRemove } from "./rm.js";
import { cmdRollback } from "./rollback.js";
import { cmdShow, formatShowYaml } from "./show.js";
import type { WorkflowDispatchDeps } from "./types.js";
function usageText(): string {
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
}
export async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseAddArgv(argv);
if (!parsed.ok) {
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdAdd(storageRoot, parsed.value);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const w of result.value.warnings) {
printCliWarn(w);
}
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
return 0;
}
export async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${usageText()}\n\nerror: list takes no arguments`);
return 1;
}
const result = await cmdList(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of formatListLines(result.value)) {
printCliLine(line);
}
return 0;
}
export async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: show requires <name>`);
return 1;
}
const result = await cmdShow(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(formatShowYaml(name, result.value));
return 0;
}
export async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: remove requires <name>`);
return 1;
}
const result = await cmdRemove(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed workflow "${name}" from registry`);
return 0;
}
export async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usageText()}\n\nerror: history requires <name>`);
return 1;
}
const result = await cmdHistory(storageRoot, name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const line of result.value) {
printCliLine(line);
}
return 0;
}
export async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 2) {
printCliError(`${usageText()}\n\nerror: rollback requires <name> [hash]`);
return 1;
}
const hashArg = argv[1];
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`rolled back workflow "${name}"`);
return 0;
}
export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
add: {
handler: dispatchAdd,
args: "<name> <file.esm.js> [--types <path>]",
description: "Register a workflow bundle in the registry",
},
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
show: {
handler: dispatchShow,
args: "<name>",
description: "Show details of a registered workflow",
},
rm: {
handler: dispatchRemove,
args: "<name>",
description: "Remove a workflow from the registry",
},
history: {
handler: dispatchHistory,
args: "<name>",
description: "Show version history of a workflow",
},
rollback: {
handler: dispatchRollback,
args: "<name> [hash]",
description: "Rollback a workflow to a previous version",
},
};
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
const { dispatchGroup, printDeprecation } = deps;
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
if (sub === "remove") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1));
}
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
return 1;
};
}
@@ -6,7 +6,7 @@ import {
readWorkflowRegistry, readWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { validateCliWorkflowName } from "./workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
export async function cmdHistory( export async function cmdHistory(
storageRoot: string, storageRoot: string,
@@ -0,0 +1,18 @@
export { cmdAdd, formatAddSuccess } from "./add.js";
export { parseAddArgv } from "./add-argv.js";
export {
createWorkflowDispatcher,
dispatchAdd,
dispatchHistory,
dispatchList,
dispatchRemove,
dispatchRollback,
dispatchShow,
WORKFLOW_SUBCOMMAND_TABLE,
} from "./dispatch.js";
export { cmdHistory } from "./history.js";
export { cmdList, formatListLines } from "./list.js";
export { cmdRemove } from "./rm.js";
export { cmdRollback } from "./rollback.js";
export { cmdShow, formatShowYaml } from "./show.js";
export type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
@@ -7,7 +7,7 @@ import {
writeWorkflowRegistry, writeWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { validateCliWorkflowName } from "./workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> { export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
const nameOk = validateCliWorkflowName(name); const nameOk = validateCliWorkflowName(name);
@@ -10,8 +10,8 @@ import {
writeWorkflowRegistry, writeWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { pathExists } from "./fs-utils.js"; import { pathExists } from "../../fs-utils.js";
import { validateCliWorkflowName } from "./workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
export async function cmdRollback( export async function cmdRollback(
storageRoot: string, storageRoot: string,
@@ -8,7 +8,7 @@ import {
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import { stringify } from "yaml"; import { stringify } from "yaml";
import { validateCliWorkflowName } from "./workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
export async function cmdShow( export async function cmdShow(
storageRoot: string, storageRoot: string,
@@ -0,0 +1,18 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type ParsedAddArgv = {
name: string;
filePath: string;
/** Override path to `.d.ts` when adding a bundle. */
typesPath: string | null;
};
export type CmdAddSuccess = {
hash: string;
warnings: ReadonlyArray<string>;
};
export type WorkflowDispatchDeps = {
dispatchGroup: DispatchGroupFn;
printDeprecation: (oldCmd: string, newCmd: string) => void;
};
@@ -1,4 +1,4 @@
import { getCommandRegistry } from "./cli-dispatch.js"; import { getCommandRegistry } from "./cli-registry.js";
type SkillTopic = { type SkillTopic = {
name: string; name: string;
@@ -126,13 +126,13 @@ uncaged-workflow thread list
## CAS (Content-Addressable Storage) ## CAS (Content-Addressable Storage)
Store and retrieve content by hash, scoped to the current thread. Store and retrieve content by hash in workflow storage (global CAS directory).
| Operation | Command | | Operation | Command |
|-----------|---------| |-----------|---------|
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash | | **Store** | \`uncaged-workflow cas put '<content>'\` → prints hash |
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content | | **Read** | \`uncaged-workflow cas get <HASH>\` → prints content |
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` | | **List** | \`uncaged-workflow cas list\` |
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files. CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
+23 -24
View File
@@ -3,6 +3,23 @@ import { join } from "node:path";
import { pathExists, readTextFileIfExists } from "./fs-utils.js"; import { pathExists, readTextFileIfExists } from "./fs-utils.js";
function parseFirstJsonLineObject(text: string): Record<string, unknown> | null {
const firstLine = text.split("\n")[0];
if (firstLine === undefined || firstLine.trim() === "") {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(firstLine) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") {
return null;
}
return parsed as Record<string, unknown>;
}
export type RunningThreadRow = { export type RunningThreadRow = {
threadId: string; threadId: string;
hash: string; hash: string;
@@ -20,20 +37,11 @@ async function readThreadStartTimestampMs(dataPath: string): Promise<number | nu
if (text === null) { if (text === null) {
return null; return null;
} }
const firstLine = text.split("\n")[0]; const parsed = parseFirstJsonLineObject(text);
if (firstLine === undefined || firstLine.trim() === "") { if (parsed === null) {
return null; return null;
} }
let parsed: unknown; const ts = parsed.timestamp;
try {
parsed = JSON.parse(firstLine) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") {
return null;
}
const ts = (parsed as Record<string, unknown>).timestamp;
return typeof ts === "number" && Number.isFinite(ts) ? ts : null; return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
} }
@@ -42,20 +50,11 @@ async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string |
if (text === null) { if (text === null) {
return null; return null;
} }
const firstLine = text.split("\n")[0]; const parsed = parseFirstJsonLineObject(text);
if (firstLine === undefined || firstLine.trim() === "") { if (parsed === null) {
return null; return null;
} }
let parsed: unknown; const name = parsed.name;
try {
parsed = JSON.parse(firstLine) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") {
return null;
}
const name = (parsed as Record<string, unknown>).name;
return typeof name === "string" ? name : null; return typeof name === "string" ? name : null;
} }
+24
View File
@@ -237,6 +237,30 @@ export async function sendWorkerTcpCommand(
}); });
} }
export async function readWorkerCtl(
storageRoot: string,
hash: string,
): Promise<Result<WorkerCtl, string>> {
const ctlPath = join(storageRoot, "workers", `${hash}.json`);
const ctlText = await readTextFileIfExists(ctlPath);
if (ctlText === null) {
return err(`worker control file missing for bundle hash ${hash}`);
}
let ctl: WorkerCtl;
try {
ctl = JSON.parse(ctlText) as WorkerCtl;
} catch {
return err(`corrupt worker control file: ${ctlPath}`);
}
if (typeof ctl.port !== "number" || ctl.port <= 0) {
return err(`invalid worker control file: ${ctlPath}`);
}
return ok(ctl);
}
export async function resolveRunningHashForThread( export async function resolveRunningHashForThread(
storageRoot: string, storageRoot: string,
threadId: string, threadId: string,
+36
View File
@@ -0,0 +1,36 @@
# @uncaged/workflow-agent-cursor
`AgentFn` adapter that runs the `cursor-agent` CLI against a workspace path derived from the thread.
The agent builds a full prompt (system + task + step history via `@uncaged/workflow-util-agent`), extracts the absolute workspace path with your `extract` + Zod schema, then spawns `cursor-agent` with `--workspace`, model, and non-interactive flags.
## Install
```bash
bun add @uncaged/workflow-agent-cursor @uncaged/workflow @uncaged/workflow-util-agent zod
```
In this monorepo: `"@uncaged/workflow-agent-cursor": "workspace:*"` plus `workspace:*` for `@uncaged/workflow` and `@uncaged/workflow-util-agent`.
## Usage
```typescript
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
const agent = createCursorAgent({
model: null, // null → "auto"
timeout: 0, // ms; 0 = no limit (spawnCli timeout disabled)
extract: myExtractFn,
});
```
## API overview
| Export | Description |
|--------|-------------|
| `createCursorAgent(config)` | Returns `AgentFn` that runs `cursor-agent` with `buildAgentPrompt(ctx)` |
| `CursorAgentConfig` | `model`, `timeout`, `extract` (must supply workspace path) |
| `validateCursorAgentConfig` | Config validation result |
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
Requires `cursor-agent` on `PATH` at runtime.
+35
View File
@@ -0,0 +1,35 @@
# @uncaged/workflow-agent-hermes
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
## Install
```bash
bun add @uncaged/workflow-agent-hermes @uncaged/workflow @uncaged/workflow-util-agent
```
In this monorepo: use `workspace:*` for all three `@uncaged/*` packages.
## Usage
```typescript
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
const agent = createHermesAgent({
model: "your-model", // or null to omit --model
timeout: 600_000, // ms, or null for no timeout
});
```
## API overview
| Export | Description |
|--------|-------------|
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
| `HermesAgentConfig` | `model`, `timeout` |
| `validateHermesAgentConfig` | Config validation result |
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
Requires `hermes` on `PATH` at runtime.
+34
View File
@@ -0,0 +1,34 @@
# @uncaged/workflow-agent-llm
`AgentFn` adapter that calls an OpenAI-compatible `POST /chat/completions` endpoint using `@uncaged/workflow`’s `LlmProvider` (base URL, API key, model).
Single-turn: system text is the current role’s `systemPrompt`, user text is the thread’s initial prompt (`ctx.start.content`). Errors from HTTP, JSON, or empty choices are thrown as `Error` with a JSON payload string.
## Install
```bash
bun add @uncaged/workflow-agent-llm @uncaged/workflow
```
In this monorepo: `"@uncaged/workflow-agent-llm": "workspace:*"`, `"@uncaged/workflow": "workspace:*"`.
## Usage
```typescript
import { createLlmAdapter } from "@uncaged/workflow-agent-llm";
const agent = createLlmAdapter({
baseUrl: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY!,
model: "gpt-4.1-mini",
});
```
## API overview
| Export | Description |
|--------|-------------|
| `createLlmAdapter(provider)` | `LlmProvider``AgentFn` |
| `chatCompletionText({ provider, messages })` | Low-level `Result<string, LlmChatError>` helper |
| `LlmMessage` | `{ role: "system" \| "user" \| "assistant"; content: string }` |
| `LlmChatError` | Discriminated error kinds for failed completions |
@@ -0,0 +1,53 @@
# @uncaged/workflow-template-develop
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
Export a `WorkflowDefinition` and `createDevelopRun` so a host can bind agents/LLM and run the same graph the bundled `.esm.js` would use. Use `buildDevelopDescriptor()` when assembling `descriptor` metadata for a bundle.
## Install
```bash
bun add @uncaged/workflow-template-develop @uncaged/workflow zod
```
In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@uncaged/workflow`.
## Usage
```typescript
import { createDevelopRun, developWorkflowDefinition } from "@uncaged/workflow-template-develop";
const run = createDevelopRun(binding, extract, llmProvider);
// run(...) executes the develop moderator graph with your AgentBinding
```
## Roles
| Role | Purpose |
|------|---------|
| **planner** | Break work into ordered phases (hashes) |
| **coder** | Implement current phase; repeats until phases complete or limits hit |
| **reviewer** | Code review gate (`approved` vs send back to coder) |
| **tester** | Verify via tests/build/lint (`passed` vs send back to coder) |
| **committer** | Final commit step |
Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `DevelopMeta`, `developRoles`.
## Moderator flow
1. **Start**`planner`
2. After **planner**`coder`
3. After **coder** → if all planned phases are done (or last phase completed) → `reviewer`; else `coder` again, until `maxRounds` then `END`
4. After **reviewer** → if approved → `tester`; else `coder` (or `END` if out of rounds)
5. After **tester** → if passed → `committer`; else `coder` (or `END` if out of rounds)
6. After **committer**`END`
## API overview
| Export | Description |
|--------|-------------|
| `createDevelopRun` | `createWorkflow(developWorkflowDefinition, …)` factory |
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
| `developModerator` | `Moderator<DevelopMeta>` |
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
| `DEVELOP_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
@@ -12,8 +12,27 @@ export const reviewerMetaSchema = z.discriminatedUnion("status", [
]); ]);
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>; export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict. const REVIEWER_SYSTEM = `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
Only reject for blocking issues. End with your verdict.`;
## Review process
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
2. Review the diff against these conventions.
3. For documentation changes, verify that names, paths, and references match the actual codebase.
## Review checklist
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
- **Conventions** — naming, imports, code style per project rules?
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
- **Edge cases** — missing error handling, null checks, boundary conditions?
## Verdict
- **Approve** only if there are zero issues
- **Reject** with specific issues that must be fixed — every issue you find is blocking
Be thorough. A false approve costs more than a false reject.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = { export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.", description: "Runs git diff checks and sets approved when the change is ready.",
@@ -0,0 +1,48 @@
# @uncaged/workflow-template-solve-issue
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
`createSolveIssueRun` wires the `developer` role to `workflowAsAgent("develop")` by default; `binding.overrides.developer` wins if you pass one (for tests or custom hosts).
## Install
```bash
bun add @uncaged/workflow-template-solve-issue @uncaged/workflow zod
```
In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
## Usage
```typescript
import { createSolveIssueRun, solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
const run = createSolveIssueRun(binding, extract, llmProvider);
```
## Roles
| Role | Purpose |
|------|---------|
| **preparer** | Set up context / repo state for the issue |
| **developer** | Implementation; default runs the registered `develop` workflow as a sub-agent |
| **submitter** | Finalize and submit the outcome (e.g. PR) |
Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod meta schemas, `SolveIssueMeta`, `solveIssueRoles`.
## Moderator flow
1. **Start**`preparer`
2. After **preparer**`developer`
3. After **developer**`submitter`
4. After **submitter**`END`
## API overview
| Export | Description |
|--------|-------------|
| `createSolveIssueRun` | Merges `developer` override with `workflowAsAgent("develop")`, then `createWorkflow` |
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
| `SOLVE_ISSUE_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
+34
View File
@@ -0,0 +1,34 @@
# @uncaged/workflow-util-agent
Shared helpers for CLI-backed workflow agents: assemble prompts from thread context and spawn subprocesses with timeouts.
Used by `@uncaged/workflow-agent-cursor` and `@uncaged/workflow-agent-hermes`. Depends on `@uncaged/workflow` for CAS reads (`getContentMerklePayload`) and `Result` typing.
## Install
```bash
bun add @uncaged/workflow-util-agent @uncaged/workflow
```
In this monorepo: `workspace:*` for both packages.
## Usage
```typescript
import { buildAgentPrompt, spawnCli } from "@uncaged/workflow-util-agent";
const prompt = await buildAgentPrompt(agentContext);
const result = await spawnCli("my-cli", ["--json"], { cwd: "/tmp", timeoutMs: 60_000 });
if (!result.ok) { /* handle SpawnCliError */ }
const stdout = result.value;
```
## API overview
| Export | Description |
|--------|-------------|
| `buildAgentPrompt(ctx)` | System prompt + task + prior step summaries + latest body from CAS; appends `uncaged-workflow thread <id>` tool hint |
| `spawnCli(cmd, args, { cwd, timeoutMs })` | `Promise<Result<string, SpawnCliError>>`; captures stdout, non-zero exit and spawn failures as `err` |
| `SpawnCliConfig` | `cwd: string \| null`, `timeoutMs: number \| null` |
| `SpawnCliError` | `non_zero_exit` \| `timeout` \| `spawn_failed` |
| `SpawnCliResult` | Alias for `Result<string, SpawnCliError>` |
+36
View File
@@ -0,0 +1,36 @@
# @uncaged/workflow
Core workflow engine: registry, CAS, thread execution, bundle validation, and role/workflow types.
This package implements the three-phase engine loop that runs single-file ESM workflow bundles (each exports `run` and `descriptor`). It persists threads under `~/.uncaged/workflow/` by default and hashes bundles with XXH64 (Crockford Base32). See the repo root [README](../../README.md) for workflow, bundle, thread, role, and registry concepts.
## Install
```bash
bun add @uncaged/workflow zod
```
In this monorepo, depend with `"@uncaged/workflow": "workspace:*"`. `zod` is a peer dependency (used by bundle/shape validation at the integration boundary).
## Usage
```typescript
import { createWorkflow, readWorkflowRegistry, executeThread } from "@uncaged/workflow";
// Wire a WorkflowDefinition + AgentBinding + extract + optional LlmProvider into createWorkflow,
// then run the returned WorkflowFn inside your host (or use executeThread for disk-backed runs).
```
## API overview
| Area | Exports (representative) |
|------|--------------------------|
| **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` |
| **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` |
| **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers |
| **CAS** | `createCasStore`, `createThreadCas`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` |
| **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` |
| **Extract / LLM tools** | `llmExtract`, `reactExtract`, `createExtract`, `getExtractProvider` |
| **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role |
| **Utilities** | `createLogger`, ULID / Crockford Base32 codecs, `getDefaultWorkflowStorageRoot`, paths |
Full surface is re-exported from `src/index.ts`.
+1 -1
View File
@@ -5,7 +5,7 @@ import {
decodeCrockfordToUint64, decodeCrockfordToUint64,
encodeCrockfordBase32Bits, encodeCrockfordBase32Bits,
encodeUint64AsCrockford, encodeUint64AsCrockford,
} from "../src/base32.js"; } from "../src/util/base32.js";
describe("Crockford Base32", () => { describe("Crockford Base32", () => {
test("roundtrip 64-bit hash encoding", () => { test("roundtrip 64-bit hash encoding", () => {
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { buildDescriptor } from "../src/build-descriptor.js"; import { buildDescriptor } from "../src/bundle/build-descriptor.js";
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
import { END } from "../src/types.js"; import { END } from "../src/types.js";
import { validateWorkflowDescriptor } from "../src/workflow-descriptor.js";
describe("buildDescriptor", () => { describe("buildDescriptor", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => { test("produces a descriptor that validates and includes JSON schemas per role", () => {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { validateWorkflowBundle } from "../src/bundle-validator.js"; import { validateWorkflowBundle } from "../src/bundle/bundle-validator.js";
const minimalDescriptor = `export const descriptor = { description: "x", roles: {} }; const minimalDescriptor = `export const descriptor = { description: "x", roles: {} };
`; `;
+2 -2
View File
@@ -3,8 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore, createThreadCas } from "../src/cas.js"; import { createCasStore, createThreadCas } from "../src/cas/cas.js";
import { hashString } from "../src/hash.js"; import { hashString } from "../src/cas/hash.js";
describe("cas module exports", () => { describe("cas module exports", () => {
test("createThreadCas is a deprecated alias of createCasStore", () => { test("createThreadCas is a deprecated alias of createCasStore", () => {
+6 -6
View File
@@ -4,18 +4,18 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/create-workflow.js";
import { executeThread } from "../src/engine.js";
import { createExtract } from "../src/extract-fn.js";
import { createLogger } from "../src/logger.js";
import { import {
createContentMerkleNode, createContentMerkleNode,
getContentMerklePayload, getContentMerklePayload,
parseMerkleNode, parseMerkleNode,
serializeMerkleNode, serializeMerkleNode,
} from "../src/merkle.js"; } from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract/extract-fn.js";
import { END, type LlmProvider } from "../src/types.js"; import { END, type LlmProvider } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const plannerMetaSchema = z.object({ const plannerMetaSchema = z.object({
plan: z.string(), plan: z.string(),
@@ -4,7 +4,7 @@ import {
buildForkPlan, buildForkPlan,
parseThreadDataJsonl, parseThreadDataJsonl,
selectForkHistoricalSteps, selectForkHistoricalSteps,
} from "../src/fork-thread.js"; } from "../src/engine/fork-thread.js";
const sampleDataJsonl = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100} const sampleDataJsonl = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
{"role":"planner","contentHash":"HP0000000000000000000001","meta":{},"refs":[],"timestamp":101} {"role":"planner","contentHash":"HP0000000000000000000001","meta":{},"refs":[],"timestamp":101}
+2 -3
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { decodeCrockfordToUint64 } from "../src/base32.js"; import { decodeCrockfordToUint64 } from "../src/util/base32.js";
import { hashWorkflowBundleBytes } from "../src/hash.js";
describe("hashWorkflowBundleBytes", () => { describe("hashWorkflowBundleBytes", () => {
test("matches XXH64 reference for empty input", () => { test("matches XXH64 reference for empty input", () => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createLogger } from "../src/logger.js"; import { createLogger } from "../src/util/logger.js";
describe("createLogger", () => { describe("createLogger", () => {
test("writes JSONL records to a file sink", async () => { test("writes JSONL records to a file sink", async () => {
+5 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "../src/merkle.js"; import {
createContentMerkleNode,
parseMerkleNode,
serializeMerkleNode,
} from "../src/cas/merkle.js";
describe("merkle", () => { describe("merkle", () => {
test("content node roundtrips through YAML", () => { test("content node roundtrips through YAML", () => {
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js"; import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { reactExtract } from "../src/react-extract.js"; import { reactExtract } from "../src/extract/react-extract.js";
import type { LlmProvider } from "../src/types.js"; import type { LlmProvider } from "../src/types.js";
const metaSchema = z.object({ seen: z.string() }); const metaSchema = z.object({ seen: z.string() });
@@ -4,13 +4,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/create-workflow.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine.js"; import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract-fn.js"; import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
import { buildForkPlan, parseThreadDataJsonl } from "../src/fork-thread.js"; import { createExtract } from "../src/extract/extract-fn.js";
import { createLogger } from "../src/logger.js";
import { END } from "../src/types.js"; import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const phaseSchema = z.object({ const phaseSchema = z.object({
hash: z.string(), hash: z.string(),
+1 -1
View File
@@ -10,7 +10,7 @@ import {
rollbackWorkflowToHistoryHash, rollbackWorkflowToHistoryHash,
unregisterWorkflow, unregisterWorkflow,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "../src/registry.js"; } from "../src/registry/registry.js";
describe("workflow registry", () => { describe("workflow registry", () => {
test("roundtrips through workflow.yaml", async () => { test("roundtrips through workflow.yaml", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { err, ok } from "../src/result.js"; import { err, ok } from "../src/util/result.js";
describe("result helpers", () => { describe("result helpers", () => {
test("ok wraps value", () => { test("ok wraps value", () => {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { join } from "node:path"; import { join } from "node:path";
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "../src/storage-root.js"; import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "../src/util/storage-root.js";
describe("getGlobalCasDir", () => { describe("getGlobalCasDir", () => {
test("joins cas segment under explicit storage root", () => { test("joins cas segment under explicit storage root", () => {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createThreadPauseGate } from "../src/thread-pause-gate.js"; import { createThreadPauseGate } from "../src/engine/thread-pause-gate.js";
describe("createThreadPauseGate", () => { describe("createThreadPauseGate", () => {
test("pause blocks awaitAfterYield until resume", async () => { test("pause blocks awaitAfterYield until resume", async () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { decodeCrockfordBase32Bits } from "../src/base32.js"; import { decodeCrockfordBase32Bits } from "../src/util/base32.js";
import { generateUlid } from "../src/ulid.js"; import { generateUlid } from "../src/util/ulid.js";
describe("generateUlid", () => { describe("generateUlid", () => {
test("length and decodable Crockford payload", () => { test("length and decodable Crockford payload", () => {
+3 -3
View File
@@ -5,9 +5,9 @@ import { createConnection } from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore } from "../src/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js"; import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { getWorkerHostScriptPath } from "../src/worker-entry-path.js"; import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -4,19 +4,19 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/create-workflow.js"; import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { executeThread } from "../src/engine.js"; import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
import { createExtract } from "../src/extract-fn.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { hashWorkflowBundleBytes } from "../src/hash.js"; import { executeThread } from "../src/engine/engine.js";
import { createLogger } from "../src/logger.js"; import { createExtract } from "../src/extract/extract-fn.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/merkle.js";
import { import {
readWorkflowRegistry, readWorkflowRegistry,
registerWorkflowVersion, registerWorkflowVersion,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "../src/registry.js"; } from "../src/registry/registry.js";
import { END } from "../src/types.js"; import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js"; import { workflowAsAgent } from "../src/workflow-as-agent.js";
const callerMetaSchema = z.object({ done: z.literal(true) }); const callerMetaSchema = z.object({ done: z.literal(true) });
@@ -3,14 +3,14 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore } from "../src/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/hash.js"; import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { parseMerkleNode } from "../src/merkle.js"; import { parseMerkleNode } from "../src/cas/merkle.js";
import { import {
readWorkflowRegistry, readWorkflowRegistry,
registerWorkflowVersion, registerWorkflowVersion,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "../src/registry.js"; } from "../src/registry/registry.js";
import { type AgentContext, START } from "../src/types.js"; import { type AgentContext, START } from "../src/types.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js"; import { workflowAsAgent } from "../src/workflow-as-agent.js";
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { validateWorkflowDescriptor } from "../src/workflow-descriptor.js"; import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
describe("validateWorkflowDescriptor", () => { describe("validateWorkflowDescriptor", () => {
// 1. Valid minimal descriptor // 1. Valid minimal descriptor
@@ -1,7 +1,7 @@
import * as z from "zod/v4"; import * as z from "zod/v4";
import type { RoleMeta, WorkflowDefinition } from "./types.js"; import type { RoleMeta, WorkflowDefinition } from "../types.js";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./workflow-descriptor.js"; import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema { function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
const { $schema: _drop, ...rest } = json; const { $schema: _drop, ...rest } = json;
@@ -10,6 +10,11 @@ import type {
Program, Program,
VariableDeclaration, VariableDeclaration,
} from "acorn"; } from "acorn";
import * as acorn from "acorn";
import { err, ok, type Result } from "../util/index.js";
import type { WorkflowBundleValidationInput } from "./types.js";
/** Acorn Node with index-access for property traversal. */ /** Acorn Node with index-access for property traversal. */
type AcornNode = Node & { [key: string]: unknown }; type AcornNode = Node & { [key: string]: unknown };
@@ -22,17 +27,6 @@ function narrowNode<T extends Node>(node: Node): T {
return node as unknown as T; return node as unknown as T;
} }
import * as acorn from "acorn";
import { err, ok, type Result } from "./result.js";
export type WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */
filePath: string;
/** UTF-8 source of the bundle. */
source: string;
};
function endsWithEsmJs(path: string): boolean { function endsWithEsmJs(path: string): boolean {
return path.endsWith(".esm.js"); return path.endsWith(".esm.js");
} }
@@ -2,9 +2,9 @@ import { mkdir, readlink, symlink, unlink } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
/** This module lives in `@uncaged/workflow/src`; parent dir is the package root. */ /** This module lives in `@uncaged/workflow/src/bundle`; grandparent dir is the package root. */
function installedWorkflowPackageDir(): string { function installedWorkflowPackageDir(): string {
return fileURLToPath(new URL("..", import.meta.url)); return fileURLToPath(new URL("../..", import.meta.url));
} }
/** /**
@@ -1,20 +1,10 @@
import type { WorkflowFn } from "../types.js";
import { err, ok, type Result } from "../util/index.js";
import { importWorkflowBundleModule } from "./bundle-import-env.js"; import { importWorkflowBundleModule } from "./bundle-import-env.js";
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js"; import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
import { err, ok, type Result } from "./result.js"; import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js";
import type { WorkflowFn } from "./types.js";
import type { WorkflowDescriptor } from "./workflow-descriptor.js";
import { validateWorkflowDescriptor } from "./workflow-descriptor.js"; import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
export type ExtractedBundleExports = {
run: WorkflowFn;
descriptor: WorkflowDescriptor;
};
export type ExtractBundleExportsOptions = {
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
storageRoot: string | null;
};
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */ /** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
export async function extractBundleExports( export async function extractBundleExports(
bundlePath: string, bundlePath: string,
@@ -1,6 +1,6 @@
import { stringify } from "yaml"; import { stringify } from "yaml";
import type { WorkflowDescriptor } from "./workflow-descriptor.js"; import type { WorkflowDescriptor } from "./types.js";
/** Serialize a validated workflow descriptor to YAML for storage next to the bundle. */ /** Serialize a validated workflow descriptor to YAML for storage next to the bundle. */
export function stringifyWorkflowDescriptor(descriptor: WorkflowDescriptor): string { export function stringifyWorkflowDescriptor(descriptor: WorkflowDescriptor): string {
+15
View File
@@ -0,0 +1,15 @@
export { buildDescriptor } from "./build-descriptor.js";
export { importWorkflowBundleModule } from "./bundle-import-env.js";
export { validateWorkflowBundle } from "./bundle-validator.js";
export { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
export { extractBundleExports } from "./extract-bundle-exports.js";
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
export type {
ExtractBundleExportsOptions,
ExtractedBundleExports,
WorkflowBundleValidationInput,
WorkflowDescriptor,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./types.js";
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
+32
View File
@@ -0,0 +1,32 @@
import type { WorkflowFn } from "../types.js";
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
export type WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */
filePath: string;
/** UTF-8 source of the bundle. */
source: string;
};
export type ExtractedBundleExports = {
run: WorkflowFn;
descriptor: WorkflowDescriptor;
};
export type ExtractBundleExportsOptions = {
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
storageRoot: string | null;
};
@@ -1,18 +1,6 @@
import { err, ok, type Result } from "./result.js"; import { err, ok, type Result } from "../util/index.js";
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */ import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> { export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
if (value === null || typeof value !== "object" || Array.isArray(value)) { if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -2,13 +2,7 @@ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/pro
import { join } from "node:path"; import { join } from "node:path";
import { hashString } from "./hash.js"; import { hashString } from "./hash.js";
import type { CasStore } from "./types.js";
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
export function createCasStore(casDir: string): CasStore { export function createCasStore(casDir: string): CasStore {
async function ensureDir(): Promise<void> { async function ensureDir(): Promise<void> {
@@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
import XXH from "xxhashjs"; import XXH from "xxhashjs";
import { encodeUint64AsCrockford } from "./base32.js"; import { encodeUint64AsCrockford } from "../util/index.js";
function digestToUint64(digest: { toString(radix?: number): string }): bigint { function digestToUint64(digest: { toString(radix?: number): string }): bigint {
const hex = digest.toString(16).padStart(16, "0"); const hex = digest.toString(16).padStart(16, "0");

Some files were not shown because too many files have changed in this diff Show More