@smithers-orchestrator/cli 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +16 -16
  2. package/src/workflow-pack.js +506 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Smithers command-line interface, TUI, MCP server, and local workflow tools",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -34,21 +34,21 @@
34
34
  "picocolors": "^1.1.1",
35
35
  "react": "^19.2.5",
36
36
  "zod": "^4.3.6",
37
- "@smithers-orchestrator/accounts": "0.18.0",
38
- "@smithers-orchestrator/agents": "0.18.0",
39
- "@smithers-orchestrator/components": "0.18.0",
40
- "@smithers-orchestrator/driver": "0.18.0",
41
- "@smithers-orchestrator/engine": "0.18.0",
42
- "@smithers-orchestrator/errors": "0.18.0",
43
- "@smithers-orchestrator/observability": "0.18.0",
44
- "@smithers-orchestrator/db": "0.18.0",
45
- "@smithers-orchestrator/memory": "0.18.0",
46
- "@smithers-orchestrator/protocol": "0.18.0",
47
- "@smithers-orchestrator/openapi": "0.18.0",
48
- "@smithers-orchestrator/devtools": "0.18.0",
49
- "@smithers-orchestrator/scheduler": "0.18.0",
50
- "@smithers-orchestrator/time-travel": "0.18.0",
51
- "@smithers-orchestrator/server": "0.18.0"
37
+ "@smithers-orchestrator/agents": "0.20.0",
38
+ "@smithers-orchestrator/components": "0.20.0",
39
+ "@smithers-orchestrator/db": "0.20.0",
40
+ "@smithers-orchestrator/driver": "0.20.0",
41
+ "@smithers-orchestrator/engine": "0.20.0",
42
+ "@smithers-orchestrator/errors": "0.20.0",
43
+ "@smithers-orchestrator/accounts": "0.20.0",
44
+ "@smithers-orchestrator/devtools": "0.20.0",
45
+ "@smithers-orchestrator/protocol": "0.20.0",
46
+ "@smithers-orchestrator/scheduler": "0.20.0",
47
+ "@smithers-orchestrator/memory": "0.20.0",
48
+ "@smithers-orchestrator/observability": "0.20.0",
49
+ "@smithers-orchestrator/server": "0.20.0",
50
+ "@smithers-orchestrator/openapi": "0.20.0",
51
+ "@smithers-orchestrator/time-travel": "0.20.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/bun": "latest",
@@ -88,6 +88,8 @@ function readOwnPackageVersion() {
88
88
  */
89
89
  const BUNDLED_VERSION_PINS = {
90
90
  zod: "4.3.6",
91
+ react: "19.2.5",
92
+ reactDom: "19.2.5",
91
93
  typescript: "5.9.3",
92
94
  reactTypes: "19.2.14",
93
95
  reactDomTypes: "19.2.3",
@@ -101,6 +103,8 @@ function readDependencyVersions() {
101
103
  return {
102
104
  smithersVersion: readOwnPackageVersion(),
103
105
  zodVersion: resolveInstalledPackageVersion("zod", BUNDLED_VERSION_PINS.zod),
106
+ reactVersion: resolveInstalledPackageVersion("react", BUNDLED_VERSION_PINS.react),
107
+ reactDomVersion: resolveInstalledPackageVersion("react-dom", BUNDLED_VERSION_PINS.reactDom),
104
108
  typescriptVersion: resolveInstalledPackageVersion("typescript", BUNDLED_VERSION_PINS.typescript),
105
109
  reactTypesVersion: resolveInstalledPackageVersion("@types/react", BUNDLED_VERSION_PINS.reactTypes),
106
110
  reactDomTypesVersion: resolveInstalledPackageVersion("@types/react-dom", BUNDLED_VERSION_PINS.reactDomTypes),
@@ -121,11 +125,14 @@ function renderPackageJson(versions) {
121
125
  type: "module",
122
126
  scripts: {
123
127
  typecheck: "tsc --noEmit",
128
+ gateway: "bun ./gateway.ts",
124
129
  "workflow:list": "smithers workflow list",
125
130
  "workflow:run": "smithers workflow run",
126
131
  "workflow:implement": "smithers workflow implement",
127
132
  },
128
133
  dependencies: {
134
+ react: versions.reactVersion,
135
+ "react-dom": versions.reactDomVersion,
129
136
  skills: "github:mattpocock/skills",
130
137
  "smithers-orchestrator": smithersSpec,
131
138
  zod: versions.zodVersion,
@@ -1626,6 +1633,462 @@ function renderWorkflowFile(id, displayName, body) {
1626
1633
  ].join("\n"),
1627
1634
  };
1628
1635
  }
1636
+ function renderGatewayFile() {
1637
+ return {
1638
+ path: ".smithers/gateway.ts",
1639
+ contents: [
1640
+ 'import { Gateway, mdxPlugin } from "smithers-orchestrator";',
1641
+ 'import { dirname, resolve } from "node:path";',
1642
+ 'import { fileURLToPath } from "node:url";',
1643
+ "",
1644
+ "mdxPlugin();",
1645
+ "",
1646
+ "const here = dirname(fileURLToPath(import.meta.url));",
1647
+ "const projectRoot = resolve(here, \"..\");",
1648
+ "process.chdir(projectRoot);",
1649
+ "",
1650
+ "const { default: kanban } = await import(\"./workflows/kanban.tsx\");",
1651
+ "",
1652
+ "const parsedPort = Number(process.env.PORT ?? \"7331\");",
1653
+ "const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 7331;",
1654
+ "const host = process.env.HOST ?? \"127.0.0.1\";",
1655
+ "",
1656
+ "const gateway = new Gateway({ heartbeatMs: 15_000 });",
1657
+ "",
1658
+ "function registerWorkflow(key: string, workflow: any, options?: any) {",
1659
+ " gateway.register(key, workflow, options);",
1660
+ "}",
1661
+ "",
1662
+ "registerWorkflow(\"kanban\", kanban, {",
1663
+ " ui: {",
1664
+ " entry: resolve(here, \"ui\", \"kanban.tsx\"),",
1665
+ " title: \"Kanban\",",
1666
+ " },",
1667
+ "});",
1668
+ "",
1669
+ "await gateway.listen({ host, port });",
1670
+ "console.log(\"Smithers Gateway listening on http://\" + host + \":\" + port);",
1671
+ "console.log(\"Kanban UI mounted at http://\" + host + \":\" + port + \"/workflows/kanban\");",
1672
+ "",
1673
+ ].join("\n"),
1674
+ };
1675
+ }
1676
+ function renderKanbanUiFile() {
1677
+ return {
1678
+ path: ".smithers/ui/kanban.tsx",
1679
+ contents: [
1680
+ "/** @jsxImportSource react */",
1681
+ 'import { useMemo, useState } from "react";',
1682
+ "import {",
1683
+ " createGatewayReactRoot,",
1684
+ " useGatewayActions,",
1685
+ " useGatewayApprovals,",
1686
+ " useGatewayNodeOutput,",
1687
+ " useGatewayRunEvents,",
1688
+ " useGatewayRuns,",
1689
+ '} from "smithers-orchestrator/gateway-react";',
1690
+ "",
1691
+ 'const WORKFLOW_KEY = "kanban";',
1692
+ "",
1693
+ "type RunSummary = {",
1694
+ " runId: string;",
1695
+ " workflowKey?: string;",
1696
+ " status?: string;",
1697
+ " createdAtMs?: number;",
1698
+ " startedAtMs?: number;",
1699
+ "};",
1700
+ "",
1701
+ 'type BoardLane = "pending" | "in-progress" | "completed";',
1702
+ 'type TicketState = "pending" | "in-progress" | "finished" | "failed";',
1703
+ "",
1704
+ "type TicketSummary = {",
1705
+ " id: string;",
1706
+ " slug: string;",
1707
+ " title: string;",
1708
+ "};",
1709
+ "",
1710
+ "type TicketView = {",
1711
+ " id: string;",
1712
+ " slug: string;",
1713
+ " title: string;",
1714
+ " lane: BoardLane;",
1715
+ " state: TicketState;",
1716
+ " events: number;",
1717
+ " currentStep?: string;",
1718
+ " nodeId?: string;",
1719
+ "};",
1720
+ "",
1721
+ "const laneLabels: Record<BoardLane, string> = {",
1722
+ ' pending: "Todo",',
1723
+ ' "in-progress": "In Progress",',
1724
+ ' completed: "Done",',
1725
+ "};",
1726
+ "",
1727
+ "const laneOrder: BoardLane[] = [\"pending\", \"in-progress\", \"completed\"];",
1728
+ "",
1729
+ "const styles = [",
1730
+ ' ":root { --bg: #0c0c0e; --panel: #151518; --card: #1c1c1f; --text: #eeeeee; --muted: #8a8a8e; --border: #262629; --primary: #5e6ad2; --success: #4ade80; --error: #f87171; --warning: #fbbf24; color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, \\"Segoe UI\\", Roboto, Helvetica, Arial, sans-serif; }",',
1731
+ ' "* { box-sizing: border-box; -webkit-font-smoothing: antialiased; }",',
1732
+ ' "body { margin: 0; background: var(--bg); color: var(--text); font-size: 13px; line-height: 1.4; }",',
1733
+ ' "button, input { font: inherit; transition: all 0.1s ease; }",',
1734
+ ' ".shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }",',
1735
+ ' ".topbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg); z-index: 10; }",',
1736
+ ' ".title-group { display: flex; align-items: center; gap: 12px; }",',
1737
+ ' "h1 { margin: 0; font-size: 14px; font-weight: 600; }",',
1738
+ ' ".run-indicator { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); background: var(--panel); padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border); }",',
1739
+ ' ".toolbar { display: flex; align-items: center; gap: 12px; }",',
1740
+ ' ".field { display: flex; align-items: center; gap: 8px; }",',
1741
+ ' ".field label { color: var(--muted); font-size: 11px; font-weight: 600; text-transform: uppercase; }",',
1742
+ ' ".field input { width: 32px; border: 0; outline: none; color: var(--text); background: transparent; font-weight: 600; text-align: center; border-bottom: 1px solid var(--border); }",',
1743
+ ' ".button { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--border); border-radius: 6px; height: 28px; padding: 0 12px; background: var(--panel); color: var(--text); cursor: pointer; font-weight: 500; font-size: 12px; }",',
1744
+ ' ".button:hover { background: var(--card); border-color: #3f3f46; }",',
1745
+ ' ".button.primary { background: var(--primary); color: white; border-color: var(--primary); }",',
1746
+ ' ".button.primary:hover { opacity: 0.9; }",',
1747
+ ' ".button.danger { color: var(--error); }",',
1748
+ ' ".button:disabled { opacity: 0.4; cursor: not-allowed; }",',
1749
+ ' ".main { display: grid; grid-template-columns: 1fr 280px; flex: 1; overflow: hidden; }",',
1750
+ ' ".board { display: grid; grid-template-columns: repeat(3, 1fr); background: var(--border); gap: 1px; overflow-x: auto; height: 100%; }",',
1751
+ ' ".lane { background: var(--bg); display: flex; flex-direction: column; min-width: 300px; height: 100%; }",',
1752
+ ' ".lane-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; position: sticky; top: 0; background: var(--bg); z-index: 5; }",',
1753
+ ' ".lane-title-wrap { display: flex; align-items: center; gap: 8px; }",',
1754
+ ' ".lane-title { font-weight: 600; font-size: 12px; }",',
1755
+ ' ".count { color: var(--muted); font-size: 12px; }",',
1756
+ ' ".status-circle { width: 14px; height: 14px; border: 1.5px solid var(--muted); border-radius: 50%; display: inline-block; position: relative; }",',
1757
+ ' ".lane.in-progress .status-circle { border-color: var(--warning); border-left-color: transparent; }",',
1758
+ ' ".lane.completed .status-circle { border-color: var(--primary); background: var(--primary); }",',
1759
+ ' ".lane.completed .status-circle::after { content: \'\'; position: absolute; left: 3px; top: 1px; width: 4px; height: 7px; border: solid white; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); }",',
1760
+ ' ".cards { padding: 8px; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; }",',
1761
+ ' ".ticket { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 12px; display: flex; flex-direction: column; gap: 10px; transition: border-color 0.1s; }",',
1762
+ ' ".ticket:hover { border-color: #3f3f46; }",',
1763
+ ' ".ticket-id { font-family: monospace; font-size: 11px; color: var(--muted); }",',
1764
+ ' ".ticket-title { font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.4; }",',
1765
+ ' ".ticket-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }",',
1766
+ ' ".pill { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: #262629; color: var(--muted); border: 1px solid transparent; }",',
1767
+ ' ".pill.active { border-color: rgba(94, 106, 210, 0.4); color: #8e96ff; background: rgba(94, 106, 210, 0.1); }",',
1768
+ ' ".ticket-step { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 6px; }",',
1769
+ ' ".dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }",',
1770
+ ' ".ticket.in-progress .dot { background: var(--warning); box-shadow: 0 0 6px var(--warning); }",',
1771
+ ' ".ticket.failed .dot { background: var(--error); }",',
1772
+ ' ".ticket.finished .dot { background: var(--success); }",',
1773
+ ' ".sidebar { border-left: 1px solid var(--border); display: flex; flex-direction: column; background: var(--bg); overflow: hidden; }",',
1774
+ ' ".side-block { display: flex; flex-direction: column; flex: 1; overflow: hidden; border-bottom: 1px solid var(--border); }",',
1775
+ ' ".side-head { padding: 12px 16px; border-bottom: 1px solid var(--border); }",',
1776
+ ' ".side-head h2 { margin: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }",',
1777
+ ' ".side-list { overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 2px; }",',
1778
+ ' ".run-btn { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border-radius: 6px; cursor: pointer; border: 1px solid transparent; text-align: left; background: transparent; width: 100%; color: inherit; }",',
1779
+ ' ".run-btn:hover { background: var(--panel); }",',
1780
+ ' ".run-btn.selected { background: var(--panel); border-color: var(--border); }",',
1781
+ ' ".run-info { display: flex; align-items: center; justify-content: space-between; }",',
1782
+ ' ".run-name { font-family: monospace; font-weight: 600; font-size: 12px; }",',
1783
+ ' ".run-time { font-size: 11px; color: var(--muted); }",',
1784
+ ' ".approval-box { padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); margin-bottom: 8px; display: flex; flex-direction: column; gap: 8px; }",',
1785
+ ' ".approval-txt { font-weight: 600; font-size: 12px; }",',
1786
+ ' ".approval-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }",',
1787
+ ' ".empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 12px; font-style: italic; opacity: 0.5; }",',
1788
+ ' ".toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 8px 16px; border-radius: 8px; background: var(--card); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: flex; align-items: center; gap: 12px; z-index: 100; font-size: 12px; font-weight: 500; }",',
1789
+ ' ".error-msg { color: var(--error); }",',
1790
+ '].join(\"\\n\");',
1791
+ "",
1792
+ "function isRecord(value: unknown): value is Record<string, unknown> {",
1793
+ " return typeof value === \"object\" && value !== null;",
1794
+ "}",
1795
+ "",
1796
+ "function asString(value: unknown): string | undefined {",
1797
+ " return typeof value === \"string\" ? value : undefined;",
1798
+ "}",
1799
+ "",
1800
+ "function asNumber(value: unknown): number | undefined {",
1801
+ " return typeof value === \"number\" && Number.isFinite(value) ? value : undefined;",
1802
+ "}",
1803
+ "",
1804
+ "function shortRunId(runId: string | undefined) {",
1805
+ " return runId ? runId.slice(0, 8) : \"none\";",
1806
+ "}",
1807
+ "",
1808
+ "function formatTime(ms: number | undefined) {",
1809
+ " if (!ms) return \"--\";",
1810
+ " const d = new Date(ms);",
1811
+ " return d.toLocaleDateString([], { month: \'short\', day: \'numeric\' }) + \" \" + d.toLocaleTimeString([], { hour: \'2-digit\', minute: \'2-digit\' });",
1812
+ "}",
1813
+ "",
1814
+ "function titleFromSlug(slug: string) {",
1815
+ " return slug",
1816
+ " .replace(/__/g, \" / \")",
1817
+ " .replace(/[-_]+/g, \" \")",
1818
+ " .replace(/\\b\\w/g, (letter) => letter.toUpperCase());",
1819
+ "}",
1820
+ "",
1821
+ "function extractDiscoveredTickets(value: unknown): TicketSummary[] {",
1822
+ " const response = isRecord(value) ? value : {};",
1823
+ " const row = isRecord(response.row) ? response.row : {};",
1824
+ " const rawTickets = Array.isArray(row.tickets) ? row.tickets : [];",
1825
+ " return rawTickets.flatMap((ticket): TicketSummary[] => {",
1826
+ " if (!isRecord(ticket)) return [];",
1827
+ " const slug = asString(ticket.slug);",
1828
+ " const id = asString(ticket.id) ?? (slug ? slug + \".md\" : undefined);",
1829
+ " if (!slug || !id) return [];",
1830
+ " return [{ id, slug, title: asString(ticket.title) ?? titleFromSlug(slug) }];",
1831
+ " });",
1832
+ "}",
1833
+ "",
1834
+ "function parseTicketNode(nodeId: string): { slug: string; step: string; result: boolean } | null {",
1835
+ " if (nodeId.startsWith(\"result-\")) {",
1836
+ " const slug = nodeId.slice(\"result-\".length);",
1837
+ " return slug ? { slug, step: \"result\", result: true } : null;",
1838
+ " }",
1839
+ " const [slug, step] = nodeId.split(\":\");",
1840
+ " if (!slug || slug === \"tickets\" || slug === \"merge\" || step === \"loop\") return null;",
1841
+ " return { slug, step: step ?? \"process\", result: false };",
1842
+ "}",
1843
+ "",
1844
+ "function stepLabel(step: string) {",
1845
+ " if (step === \"implement\") return \"Implementing\";",
1846
+ " if (step === \"validate\") return \"Validating\";",
1847
+ " if (step === \"review\") return \"Reviewing\";",
1848
+ " if (step === \"result\") return \"Done\";",
1849
+ " return titleFromSlug(step);",
1850
+ "}",
1851
+ "",
1852
+ "function collectStreamEvents(events: Array<Record<string, unknown>>) {",
1853
+ " return events",
1854
+ " .map((frame) => (isRecord(frame.payload) ? frame.payload : frame))",
1855
+ " .filter((frame): frame is Record<string, unknown> => isRecord(frame));",
1856
+ "}",
1857
+ "",
1858
+ "function deriveTickets(discovered: TicketSummary[], events: Array<Record<string, unknown>>): TicketView[] {",
1859
+ " const tickets = new Map<string, TicketView>();",
1860
+ " for (const ticket of discovered) {",
1861
+ " tickets.set(ticket.slug, {",
1862
+ " id: ticket.id,",
1863
+ " slug: ticket.slug,",
1864
+ " title: ticket.title,",
1865
+ " lane: \"pending\",",
1866
+ " state: \"pending\",",
1867
+ " events: 0,",
1868
+ " currentStep: \"Backlog\",",
1869
+ " });",
1870
+ " }",
1871
+ " for (const event of events) {",
1872
+ " const eventName = asString(event.event);",
1873
+ " if (eventName !== \"node.started\" && eventName !== \"node.finished\" && eventName !== \"node.failed\") continue;",
1874
+ " const payload = isRecord(event.payload) ? event.payload : {};",
1875
+ " const nodeId = asString(payload.nodeId);",
1876
+ " if (!nodeId) continue;",
1877
+ " const parsed = parseTicketNode(nodeId);",
1878
+ " if (!parsed) continue;",
1879
+ " const existing = tickets.get(parsed.slug) ?? {",
1880
+ " id: parsed.slug + \".md\",",
1881
+ " slug: parsed.slug,",
1882
+ " title: titleFromSlug(parsed.slug),",
1883
+ " lane: \"pending\" as BoardLane,",
1884
+ " state: \"pending\" as TicketState,",
1885
+ " events: 0,",
1886
+ " };",
1887
+ " if (eventName === \"node.failed\") {",
1888
+ " existing.lane = \"completed\";",
1889
+ " existing.state = \"failed\";",
1890
+ " existing.currentStep = \"Failed: \" + stepLabel(parsed.step);",
1891
+ " } else if (parsed.result && eventName === \"node.finished\") {",
1892
+ " existing.lane = \"completed\";",
1893
+ " existing.state = \"finished\";",
1894
+ " existing.currentStep = \"Completed\";",
1895
+ " } else {",
1896
+ " existing.lane = existing.lane === \"completed\" ? existing.lane : \"in-progress\";",
1897
+ " existing.state = existing.state === \"failed\" || existing.state === \"finished\" ? existing.state : \"in-progress\";",
1898
+ " existing.currentStep = stepLabel(parsed.step);",
1899
+ " }",
1900
+ " existing.nodeId = nodeId;",
1901
+ " existing.events += 1;",
1902
+ " tickets.set(parsed.slug, existing);",
1903
+ " }",
1904
+ " return Array.from(tickets.values()).sort((left, right) => left.title.localeCompare(right.title));",
1905
+ "}",
1906
+ "",
1907
+ "function App() {",
1908
+ " const [maxConcurrency, setMaxConcurrency] = useState(3);",
1909
+ " const [selectedRunId, setSelectedRunId] = useState<string>();",
1910
+ " const [busy, setBusy] = useState(false);",
1911
+ " const [message, setMessage] = useState(\"\");",
1912
+ " const [showMsg, setShowMsg] = useState(false);",
1913
+ " const runs = useGatewayRuns({ filter: { limit: 20 } });",
1914
+ " const approvals = useGatewayApprovals({ filter: { workflow: WORKFLOW_KEY, limit: 10 } });",
1915
+ " const actions = useGatewayActions();",
1916
+ "",
1917
+ " const kanbanRuns = useMemo(() => {",
1918
+ " return ((runs.data ?? []) as RunSummary[]).filter((run) => !run.workflowKey || run.workflowKey === WORKFLOW_KEY);",
1919
+ " }, [runs.data]);",
1920
+ " const activeRunId = selectedRunId ?? kanbanRuns[0]?.runId;",
1921
+ " const activeRun = kanbanRuns.find((run) => run.runId === activeRunId);",
1922
+ " const stream = useGatewayRunEvents(activeRunId, { afterSeq: 0 });",
1923
+ " const ticketsOutput = useGatewayNodeOutput({ runId: activeRunId, nodeId: \"tickets\", iteration: 0 });",
1924
+ " const streamEvents = useMemo(() => {",
1925
+ " return collectStreamEvents(stream.events as Array<Record<string, unknown>>)",
1926
+ " .filter((event) => !activeRunId || asString(event.runId) === activeRunId);",
1927
+ " }, [activeRunId, stream.events]);",
1928
+ " const discoveredTickets = useMemo(() => extractDiscoveredTickets(ticketsOutput.data), [ticketsOutput.data]);",
1929
+ " const tickets = useMemo(() => deriveTickets(discoveredTickets, streamEvents), [discoveredTickets, streamEvents]);",
1930
+ " const pendingApprovals = approvals.data ?? [];",
1931
+ " const ticketsByLane = useMemo(() => {",
1932
+ " const grouped: Record<BoardLane, TicketView[]> = { pending: [], \"in-progress\": [], completed: [] };",
1933
+ " for (const ticket of tickets) grouped[ticket.lane].push(ticket);",
1934
+ " return grouped;",
1935
+ " }, [tickets]);",
1936
+ "",
1937
+ " function notify(msg: string) {",
1938
+ " setMessage(msg);",
1939
+ " setShowMsg(true);",
1940
+ " setTimeout(() => setShowMsg(false), 3000);",
1941
+ " }",
1942
+ "",
1943
+ " async function refresh() {",
1944
+ " await Promise.all([runs.refetch(), approvals.refetch(), ticketsOutput.refetch()]);",
1945
+ " }",
1946
+ "",
1947
+ " async function launch() {",
1948
+ " setBusy(true);",
1949
+ " try {",
1950
+ " const run = await actions.launchRun({ workflow: WORKFLOW_KEY, input: { maxConcurrency } });",
1951
+ " setSelectedRunId(run.runId);",
1952
+ " notify(\"Launched \" + shortRunId(run.runId));",
1953
+ " await refresh();",
1954
+ " } catch (e) { notify(String(e)); } finally { setBusy(false); }",
1955
+ " }",
1956
+ "",
1957
+ " async function cancelRun() {",
1958
+ " if (!activeRunId) return;",
1959
+ " setBusy(true);",
1960
+ " try {",
1961
+ " await actions.cancelRun({ runId: activeRunId });",
1962
+ " notify(\"Cancelled run\");",
1963
+ " await refresh();",
1964
+ " } catch (e) { notify(String(e)); } finally { setBusy(false); }",
1965
+ " }",
1966
+ "",
1967
+ " async function decide(approval: (typeof pendingApprovals)[number], ok: boolean) {",
1968
+ " setBusy(true);",
1969
+ " try {",
1970
+ " await actions.submitApproval({",
1971
+ " runId: approval.runId, nodeId: approval.nodeId, iteration: approval.iteration,",
1972
+ " decision: { approved: ok, note: (ok ? \'Approved\' : \'Denied\') + \' via UI\' }",
1973
+ " });",
1974
+ " notify(ok ? \'Approved\' : \'Denied\');",
1975
+ " await refresh();",
1976
+ " } catch (e) { notify(String(e)); } finally { setBusy(false); }",
1977
+ " }",
1978
+ "",
1979
+ " const hasError = !!(stream.error || ticketsOutput.error);",
1980
+ "",
1981
+ " return (",
1982
+ " <main className=\"shell\">",
1983
+ " <style>{styles}</style>",
1984
+ " <header className=\"topbar\">",
1985
+ " <div className=\"title-group\">",
1986
+ " <h1>Kanban</h1>",
1987
+ " <div className=\"run-indicator\">",
1988
+ " <div className=\"status-circle\" style={{ border: \'1.5px solid var(--primary)\', background: activeRun?.status === \'running\' ? \'var(--primary)\' : \'transparent\' }}></div>",
1989
+ " <span>{activeRunId ? shortRunId(activeRunId) : \'No Run\'}</span>",
1990
+ " <span style={{ color: activeRun?.status === \'running\' ? \'var(--warning)\' : \'var(--muted)\', fontWeight: 600 }}>",
1991
+ " {activeRun?.status ?? \'Idle\'}",
1992
+ " </span>",
1993
+ " </div>",
1994
+ " </div>",
1995
+ " <div className=\"toolbar\">",
1996
+ " <div className=\"field\">",
1997
+ " <label>Limit</label>",
1998
+ " <input",
1999
+ " type=\"number\" min={1} max={10} value={maxConcurrency}",
2000
+ " onChange={(e) => setMaxConcurrency(Math.max(1, Number(e.currentTarget.value) || 1))}",
2001
+ " />",
2002
+ " </div>",
2003
+ " <button className=\"button\" onClick={() => void refresh()} disabled={busy}>Refresh</button>",
2004
+ " {activeRun?.status === \"running\" && (",
2005
+ " <button className=\"button danger\" onClick={() => void cancelRun()} disabled={busy}>Cancel</button>",
2006
+ " )}",
2007
+ " <button className=\"button primary\" onClick={() => void launch()} disabled={busy}>Launch Run</button>",
2008
+ " </div>",
2009
+ " </header>",
2010
+ "",
2011
+ " <div className=\"main\">",
2012
+ " <div className=\"board\">",
2013
+ " {laneOrder.map((lane) => (",
2014
+ " <section className={\"lane \" + lane} key={lane}>",
2015
+ " <div className=\"lane-head\">",
2016
+ " <div className=\"lane-title-wrap\">",
2017
+ " <div className=\"status-circle\"></div>",
2018
+ " <div className=\"lane-title\">{laneLabels[lane]}</div>",
2019
+ " <div className=\"count\">{ticketsByLane[lane].length}</div>",
2020
+ " </div>",
2021
+ " </div>",
2022
+ " <div className=\"cards\">",
2023
+ " {ticketsByLane[lane].map((t) => (",
2024
+ " <article className={\"ticket \" + t.state} key={t.slug}>",
2025
+ " <span className=\"ticket-id\">{t.slug.slice(0, 12).toUpperCase()}</span>",
2026
+ " <div className=\"ticket-title\">{t.title}</div>",
2027
+ " <div className=\"ticket-meta\">",
2028
+ " <div className=\"ticket-step\">",
2029
+ " <div className=\"dot\"></div>",
2030
+ " <span>{t.currentStep ?? t.state}</span>",
2031
+ " </div>",
2032
+ " <div className={\"pill \" + (t.state === \'in-progress\' ? \'active\' : \'\')}>{t.events} Events</div>",
2033
+ " </div>",
2034
+ " </article>",
2035
+ " ))}",
2036
+ " {ticketsByLane[lane].length === 0 && <div className=\"empty\">Empty</div>}",
2037
+ " </div>",
2038
+ " </section>",
2039
+ " ))}",
2040
+ " </div>",
2041
+ "",
2042
+ " <aside className=\"sidebar\">",
2043
+ " <section className=\"side-block\">",
2044
+ " <div className=\"side-head\"><h2>Recent Runs</h2></div>",
2045
+ " <div className=\"side-list\">",
2046
+ " {kanbanRuns.map((r) => (",
2047
+ " <button",
2048
+ " key={r.runId} className={\"run-btn \" + (r.runId === activeRunId ? \'selected\' : \'\')}",
2049
+ " onClick={() => setSelectedRunId(r.runId)}",
2050
+ " >",
2051
+ " <div className=\"run-info\">",
2052
+ " <span className=\"run-name\">{shortRunId(r.runId)}</span>",
2053
+ " <div className=\"pill\">{r.status}</div>",
2054
+ " </div>",
2055
+ " <span className=\"run-time\">{formatTime(asNumber(r.startedAtMs) ?? asNumber(r.createdAtMs))}</span>",
2056
+ " </button>",
2057
+ " ))}",
2058
+ " </div>",
2059
+ " </section>",
2060
+ " <section className=\"side-block\" style={{ flex: 1.5 }}>",
2061
+ " <div className=\"side-head\"><h2>Approvals</h2></div>",
2062
+ " <div className=\"side-list\">",
2063
+ " {pendingApprovals.map((a) => (",
2064
+ " <div className=\"approval-box\" key={a.runId + a.nodeId + a.iteration}>",
2065
+ " <div className=\"approval-txt\">{a.requestTitle ?? a.nodeId}</div>",
2066
+ " <div className=\"approval-grid\">",
2067
+ " <button className=\"button\" onClick={() => void decide(a, false)} disabled={busy}>Deny</button>",
2068
+ " <button className=\"button primary\" onClick={() => void decide(a, true)} disabled={busy}>Approve</button>",
2069
+ " </div>",
2070
+ " </div>",
2071
+ " ))}",
2072
+ " {pendingApprovals.length === 0 && <div className=\"empty\">All clear</div>}",
2073
+ " </div>",
2074
+ " </section>",
2075
+ " </aside>",
2076
+ " </div>",
2077
+ "",
2078
+ " {showMsg && (",
2079
+ " <div className=\"toast\">",
2080
+ " <div className={hasError ? \"error-msg\" : \"\"}>{stream.error?.message ?? ticketsOutput.error?.message ?? message}</div>",
2081
+ " </div>",
2082
+ " )}",
2083
+ " </main>",
2084
+ " );",
2085
+ "}",
2086
+ "",
2087
+ "createGatewayReactRoot(<App />);",
2088
+ "",
2089
+ ].join("\n"),
2090
+ };
2091
+ }
1629
2092
  /**
1630
2093
  * @returns {TemplateFile[]}
1631
2094
  */
@@ -1641,7 +2104,12 @@ function renderWorkflows() {
1641
2104
  'import { ValidationLoop, implementOutputSchema, validateOutputSchema } from "../components/ValidationLoop";',
1642
2105
  'import { reviewOutputSchema } from "../components/Review";',
1643
2106
  "",
2107
+ "const inputSchema = z.object({",
2108
+ ' prompt: z.string().default("Implement the requested change."),',
2109
+ "});",
2110
+ "",
1644
2111
  "const { Workflow, smithers } = createSmithers({",
2112
+ " input: inputSchema,",
1645
2113
  " implement: implementOutputSchema,",
1646
2114
  " validate: validateOutputSchema,",
1647
2115
  " review: reviewOutputSchema,",
@@ -2626,7 +3094,21 @@ function renderWorkflows() {
2626
3094
  " summary: z.string(),",
2627
3095
  "});",
2628
3096
  "",
3097
+ "const inputSchema = z.object({",
3098
+ " maxConcurrency: z.number().int().min(1).max(10).default(3),",
3099
+ "});",
3100
+ "",
3101
+ "const ticketListSchema = z.object({",
3102
+ " tickets: z.array(z.object({",
3103
+ " id: z.string(),",
3104
+ " slug: z.string(),",
3105
+ " title: z.string(),",
3106
+ " })),",
3107
+ "});",
3108
+ "",
2629
3109
  "const { Workflow, Task, smithers, outputs } = createSmithers({",
3110
+ " input: inputSchema,",
3111
+ " tickets: ticketListSchema,",
2630
3112
  " implement: implementOutputSchema,",
2631
3113
  " validate: validateOutputSchema,",
2632
3114
  " review: reviewOutputSchema,",
@@ -2650,6 +3132,16 @@ function renderWorkflows() {
2650
3132
  " }",
2651
3133
  "}",
2652
3134
  "",
3135
+ "function ticketTitle(ticket: { id: string; slug: string; content: string }): string {",
3136
+ ' const heading = ticket.content.match(/^#\\s+(.+)$/m)?.[1]?.trim();',
3137
+ " return heading && heading.length > 0",
3138
+ " ? heading",
3139
+ " : ticket.slug",
3140
+ ' .replace(/__/g, " / ")',
3141
+ ' .replace(/[-_]+/g, " ")',
3142
+ " .replace(/\\b\\w/g, (letter) => letter.toUpperCase());",
3143
+ "}",
3144
+ "",
2653
3145
  "/** Build feedback string from validation + review outputs for a ticket. */",
2654
3146
  "function buildFeedback(",
2655
3147
  " ctx: any,",
@@ -2696,12 +3188,22 @@ function renderWorkflows() {
2696
3188
  "",
2697
3189
  "export default smithers((ctx) => {",
2698
3190
  " const tickets = discoverTickets();",
2699
- " const maxConcurrency = Number(ctx.input.maxConcurrency) || 3;",
3191
+ " const maxConcurrency = ctx.input.maxConcurrency;",
2700
3192
  " const ticketResults = ctx.outputs.ticketResult ?? [];",
2701
3193
  "",
2702
3194
  " return (",
2703
3195
  ' <Workflow name="kanban">',
2704
3196
  " <Sequence>",
3197
+ " <Task id=\"tickets\" output={outputs.tickets}>",
3198
+ " {{",
3199
+ " tickets: tickets.map((ticket) => ({",
3200
+ " id: ticket.id,",
3201
+ " slug: ticket.slug,",
3202
+ " title: ticketTitle(ticket),",
3203
+ " })),",
3204
+ " }}",
3205
+ " </Task>",
3206
+ "",
2705
3207
  " {/* Implement each ticket in its own worktree branch, in parallel */}",
2706
3208
  " <Parallel maxConcurrency={maxConcurrency}>",
2707
3209
  " {tickets.map((ticket) => {",
@@ -2798,6 +3300,7 @@ function renderTemplateFiles(versions, env) {
2798
3300
  path: ".smithers/preload.ts",
2799
3301
  contents: ['import { mdxPlugin } from "smithers-orchestrator";', "", "mdxPlugin();", ""].join("\n"),
2800
3302
  },
3303
+ renderGatewayFile(),
2801
3304
  ...renderAgentScaffoldFiles(),
2802
3305
  {
2803
3306
  path: ".smithers/agents.ts",
@@ -2819,6 +3322,7 @@ function renderTemplateFiles(versions, env) {
2819
3322
  ...renderPrompts(),
2820
3323
  ...renderComponents(),
2821
3324
  ...renderWorkflows(),
3325
+ renderKanbanUiFile(),
2822
3326
  {
2823
3327
  path: ".smithers/tickets/.gitkeep",
2824
3328
  contents: "",
@@ -2847,6 +3351,7 @@ export function initWorkflowPack(options = {}) {
2847
3351
  const env = process.env;
2848
3352
  ensureDir(resolve(rootDir, "prompts"));
2849
3353
  ensureDir(resolve(rootDir, "components"));
3354
+ ensureDir(resolve(rootDir, "ui"));
2850
3355
  ensureDir(resolve(rootDir, "workflows"));
2851
3356
  ensureDir(resolve(rootDir, "tickets"));
2852
3357
  const executionsDir = resolve(rootDir, "executions");