keystone-cli 1.0.3 → 1.1.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 (153) hide show
  1. package/README.md +276 -32
  2. package/package.json +8 -4
  3. package/src/cli.ts +350 -416
  4. package/src/commands/doc.ts +31 -0
  5. package/src/commands/event.ts +29 -0
  6. package/src/commands/graph.ts +37 -0
  7. package/src/commands/index.ts +14 -0
  8. package/src/commands/init.ts +185 -0
  9. package/src/commands/run.ts +124 -0
  10. package/src/commands/schema.ts +40 -0
  11. package/src/commands/utils.ts +78 -0
  12. package/src/commands/validate.ts +111 -0
  13. package/src/db/workflow-db.test.ts +314 -0
  14. package/src/db/workflow-db.ts +810 -210
  15. package/src/expression/evaluator-audit.test.ts +4 -2
  16. package/src/expression/evaluator.test.ts +14 -1
  17. package/src/expression/evaluator.ts +166 -19
  18. package/src/parser/config-schema.ts +18 -0
  19. package/src/parser/schema.ts +153 -22
  20. package/src/parser/test-schema.ts +6 -6
  21. package/src/parser/workflow-parser.test.ts +24 -0
  22. package/src/parser/workflow-parser.ts +65 -3
  23. package/src/runner/auto-heal.test.ts +5 -6
  24. package/src/runner/blueprint-executor.test.ts +2 -2
  25. package/src/runner/debug-repl.test.ts +5 -8
  26. package/src/runner/debug-repl.ts +59 -16
  27. package/src/runner/durable-timers.test.ts +11 -2
  28. package/src/runner/engine-executor.test.ts +1 -1
  29. package/src/runner/events.ts +57 -0
  30. package/src/runner/executors/artifact-executor.ts +166 -0
  31. package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
  32. package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
  33. package/src/runner/executors/file-executor.test.ts +48 -0
  34. package/src/runner/executors/file-executor.ts +324 -0
  35. package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
  36. package/src/runner/executors/human-executor.ts +144 -0
  37. package/src/runner/executors/join-executor.ts +75 -0
  38. package/src/runner/executors/llm-executor.ts +1266 -0
  39. package/src/runner/executors/memory-executor.ts +71 -0
  40. package/src/runner/executors/plan-executor.ts +104 -0
  41. package/src/runner/executors/request-executor.ts +265 -0
  42. package/src/runner/executors/script-executor.ts +43 -0
  43. package/src/runner/executors/shell-executor.ts +403 -0
  44. package/src/runner/executors/subworkflow-executor.ts +114 -0
  45. package/src/runner/executors/types.ts +69 -0
  46. package/src/runner/executors/wait-executor.ts +59 -0
  47. package/src/runner/join-scheduling.test.ts +197 -0
  48. package/src/runner/llm-adapter-runtime.test.ts +209 -0
  49. package/src/runner/llm-adapter.test.ts +419 -24
  50. package/src/runner/llm-adapter.ts +130 -26
  51. package/src/runner/llm-clarification.test.ts +2 -1
  52. package/src/runner/llm-executor.test.ts +532 -17
  53. package/src/runner/mcp-client-audit.test.ts +1 -2
  54. package/src/runner/mcp-client.ts +136 -46
  55. package/src/runner/mcp-manager.test.ts +4 -0
  56. package/src/runner/mcp-server.test.ts +58 -0
  57. package/src/runner/mcp-server.ts +26 -0
  58. package/src/runner/memoization.test.ts +190 -0
  59. package/src/runner/optimization-runner.ts +4 -9
  60. package/src/runner/quality-gate.test.ts +69 -0
  61. package/src/runner/reflexion.test.ts +6 -17
  62. package/src/runner/resource-pool.ts +102 -14
  63. package/src/runner/services/context-builder.ts +144 -0
  64. package/src/runner/services/secret-manager.ts +105 -0
  65. package/src/runner/services/workflow-validator.ts +131 -0
  66. package/src/runner/shell-executor.test.ts +28 -4
  67. package/src/runner/standard-tools-ast.test.ts +196 -0
  68. package/src/runner/standard-tools-execution.test.ts +27 -0
  69. package/src/runner/standard-tools-integration.test.ts +6 -10
  70. package/src/runner/standard-tools.ts +339 -102
  71. package/src/runner/step-executor.test.ts +216 -4
  72. package/src/runner/step-executor.ts +69 -941
  73. package/src/runner/stream-utils.ts +7 -3
  74. package/src/runner/test-harness.ts +20 -1
  75. package/src/runner/timeout.test.ts +10 -0
  76. package/src/runner/timeout.ts +11 -2
  77. package/src/runner/tool-integration.test.ts +1 -1
  78. package/src/runner/wait-step.test.ts +102 -0
  79. package/src/runner/workflow-runner.test.ts +208 -15
  80. package/src/runner/workflow-runner.ts +890 -818
  81. package/src/runner/workflow-scheduler.ts +75 -0
  82. package/src/runner/workflow-state.ts +269 -0
  83. package/src/runner/workflow-subflows.test.ts +13 -12
  84. package/src/scripts/generate-schemas.ts +16 -0
  85. package/src/templates/agents/explore.md +1 -0
  86. package/src/templates/agents/general.md +1 -0
  87. package/src/templates/agents/handoff-router.md +14 -0
  88. package/src/templates/agents/handoff-specialist.md +15 -0
  89. package/src/templates/agents/keystone-architect.md +13 -44
  90. package/src/templates/agents/my-agent.md +1 -0
  91. package/src/templates/agents/software-engineer.md +1 -0
  92. package/src/templates/agents/summarizer.md +1 -0
  93. package/src/templates/agents/test-agent.md +1 -0
  94. package/src/templates/agents/tester.md +1 -0
  95. package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
  96. package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
  97. package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
  98. package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
  99. package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
  100. package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
  101. package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
  102. package/src/templates/control-flow/idempotency-example.yaml +30 -0
  103. package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
  104. package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
  105. package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
  106. package/src/templates/features/artifact-example.yaml +39 -0
  107. package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
  108. package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
  109. package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
  110. package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
  111. package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
  112. package/src/templates/features/script-example.yaml +27 -0
  113. package/src/templates/patterns/agent-handoff.yaml +53 -0
  114. package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
  115. package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
  116. package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
  117. package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
  118. package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
  119. package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
  120. package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
  121. package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
  122. package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
  123. package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
  124. package/src/templates/scaffolding/review-loop.yaml +97 -0
  125. package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
  126. package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
  127. package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
  128. package/src/templates/testing/invalid.yaml +6 -0
  129. package/src/ui/dashboard.tsx +191 -33
  130. package/src/utils/auth-manager.test.ts +337 -0
  131. package/src/utils/auth-manager.ts +157 -61
  132. package/src/utils/blueprint-utils.ts +4 -6
  133. package/src/utils/config-loader.test.ts +2 -0
  134. package/src/utils/config-loader.ts +12 -3
  135. package/src/utils/constants.ts +76 -0
  136. package/src/utils/container.ts +63 -0
  137. package/src/utils/context-injector.test.ts +200 -0
  138. package/src/utils/context-injector.ts +244 -0
  139. package/src/utils/doc-generator.ts +85 -0
  140. package/src/utils/env-filter.ts +45 -0
  141. package/src/utils/json-parser.test.ts +12 -0
  142. package/src/utils/json-parser.ts +30 -5
  143. package/src/utils/logger.ts +12 -1
  144. package/src/utils/mermaid.ts +4 -0
  145. package/src/utils/paths.ts +52 -1
  146. package/src/utils/process-sandbox-worker.test.ts +46 -0
  147. package/src/utils/process-sandbox.ts +227 -14
  148. package/src/utils/redactor.test.ts +11 -6
  149. package/src/utils/redactor.ts +25 -9
  150. package/src/utils/sandbox.ts +3 -0
  151. package/src/runner/llm-executor.ts +0 -638
  152. package/src/runner/shell-executor.ts +0 -366
  153. package/src/templates/invalid.yaml +0 -5
package/src/cli.ts CHANGED
@@ -1,479 +1,305 @@
1
1
  #!/usr/bin/env bun
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs';
3
+ import type { FSWatcher } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
4
5
  import { Command } from 'commander';
5
6
 
6
- import exploreAgent from './templates/agents/explore.md' with { type: 'text' };
7
- import generalAgent from './templates/agents/general.md' with { type: 'text' };
8
- import architectAgent from './templates/agents/keystone-architect.md' with { type: 'text' };
9
- import softwareEngineerAgent from './templates/agents/software-engineer.md' with { type: 'text' };
10
- import summarizerAgent from './templates/agents/summarizer.md' with { type: 'text' };
11
- import testerAgent from './templates/agents/tester.md' with { type: 'text' };
12
- import decomposeImplementWorkflow from './templates/decompose-implement.yaml' with { type: 'text' };
13
- import decomposeWorkflow from './templates/decompose-problem.yaml' with { type: 'text' };
14
- import decomposeResearchWorkflow from './templates/decompose-research.yaml' with { type: 'text' };
15
- import decomposeReviewWorkflow from './templates/decompose-review.yaml' with { type: 'text' };
16
- import devWorkflow from './templates/dev.yaml' with { type: 'text' };
17
- // Default templates
18
- import scaffoldWorkflow from './templates/scaffold-feature.yaml' with { type: 'text' };
19
- import scaffoldGenerateWorkflow from './templates/scaffold-generate.yaml' with { type: 'text' };
20
- import scaffoldPlanWorkflow from './templates/scaffold-plan.yaml' with { type: 'text' };
21
-
22
7
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
8
+ import { MemoryDb } from './db/memory-db.ts';
23
9
  import { WorkflowDb, type WorkflowRun } from './db/workflow-db.ts';
10
+ import { ExpressionEvaluator } from './expression/evaluator.ts';
11
+ import type { Workflow } from './parser/schema.ts';
24
12
  import type { TestDefinition } from './parser/test-schema.ts';
25
13
  import { WorkflowParser } from './parser/workflow-parser.ts';
26
14
  import { WorkflowSuspendedError, WorkflowWaitingError } from './runner/step-executor.ts';
27
15
  import { TestHarness } from './runner/test-harness.ts';
28
16
  import { ConfigLoader } from './utils/config-loader.ts';
29
17
  import { LIMITS } from './utils/constants.ts';
30
- import { ConsoleLogger } from './utils/logger.ts';
31
- import { generateMermaidGraph, renderWorkflowAsAscii } from './utils/mermaid.ts';
18
+ import { container } from './utils/container.ts';
19
+ import { ConsoleLogger, SilentLogger } from './utils/logger.ts';
32
20
  import { WorkflowRegistry } from './utils/workflow-registry.ts';
33
21
 
22
+ // Import modular commands
23
+ import {
24
+ parseInputs,
25
+ registerDocCommand,
26
+ registerEventCommand,
27
+ registerGraphCommand,
28
+ registerInitCommand,
29
+ registerRunCommand,
30
+ registerSchemaCommand,
31
+ registerValidateCommand,
32
+ } from './commands/index.ts';
33
+
34
34
  import pkg from '../package.json' with { type: 'json' };
35
35
 
36
+ // Bootstrap DI container with default services
37
+ container.factory('logger', () => new ConsoleLogger());
38
+ container.factory('db', () => new WorkflowDb());
39
+ container.factory('memoryDb', () => new MemoryDb());
40
+
36
41
  const program = new Command();
37
42
  const defaultRetentionDays = ConfigLoader.load().storage?.retention_days ?? 30;
38
- const MAX_INPUT_STRING_LENGTH = LIMITS.MAX_INPUT_STRING_LENGTH;
39
43
 
40
44
  program
41
45
  .name('keystone')
42
46
  .description('A local-first, declarative, agentic workflow orchestrator')
43
47
  .version(pkg.version);
44
48
 
45
- /**
46
- * Parse CLI input pairs (key=value) into a record.
47
- * Attempts JSON parsing for complex types, falls back to string for simple values.
48
- *
49
- * @param pairs Array of key=value strings
50
- * @returns Record of parsed inputs
51
- */
52
- const parseInputs = (pairs?: string[]): Record<string, unknown> => {
53
- const inputs: Record<string, unknown> = Object.create(null);
54
- const blockedKeys = new Set(['__proto__', 'prototype', 'constructor']);
55
- if (!pairs) return inputs;
56
- for (const pair of pairs) {
57
- const index = pair.indexOf('=');
58
- if (index <= 0) {
59
- console.warn(`⚠️ Invalid input format: "${pair}" (expected key=value)`);
60
- continue;
61
- }
62
- const key = pair.slice(0, index);
63
- const value = pair.slice(index + 1);
49
+ // Register modular commands
50
+ registerInitCommand(program);
51
+ registerValidateCommand(program);
52
+ registerGraphCommand(program);
53
+ registerDocCommand(program);
54
+ registerSchemaCommand(program);
55
+ registerEventCommand(program);
56
+ registerRunCommand(program);
57
+
58
+ // Helper function used by remaining commands (rerun)
59
+ const collectDownstreamSteps = (workflow: Workflow, fromStepId: string): string[] => {
60
+ const stepIds = new Set(workflow.steps.map((step) => step.id));
61
+ if (!stepIds.has(fromStepId)) {
62
+ throw new Error(`Step not found in workflow: ${fromStepId}`);
63
+ }
64
64
 
65
- // Validate key format (no special characters that could cause issues)
66
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
67
- console.warn(`⚠️ Invalid input key: "${key}" (use alphanumeric and underscores only)`);
68
- continue;
69
- }
70
- if (blockedKeys.has(key)) {
71
- console.warn(`⚠️ Invalid input key: "${key}" (reserved keyword)`);
72
- continue;
65
+ const dependents = new Map<string, Set<string>>();
66
+ for (const step of workflow.steps) {
67
+ for (const dep of step.needs) {
68
+ if (!dependents.has(dep)) {
69
+ dependents.set(dep, new Set());
70
+ }
71
+ dependents.get(dep)?.add(step.id);
73
72
  }
73
+ }
74
74
 
75
- try {
76
- // Attempt JSON parse for objects, arrays, booleans, numbers
77
- const parsed = JSON.parse(value);
78
- if (typeof parsed === 'string') {
79
- if (parsed.length > MAX_INPUT_STRING_LENGTH) {
80
- console.warn(
81
- `⚠️ Input "${key}" exceeds maximum length of ${MAX_INPUT_STRING_LENGTH} characters`
82
- );
83
- continue;
84
- }
85
- if (parsed.includes('\u0000')) {
86
- console.warn(`⚠️ Input "${key}" contains invalid null characters`);
87
- continue;
88
- }
75
+ const queue = [fromStepId];
76
+ const result = new Set<string>([fromStepId]);
77
+ while (queue.length > 0) {
78
+ const current = queue.shift();
79
+ if (!current) continue;
80
+ for (const next of dependents.get(current) || []) {
81
+ if (!result.has(next)) {
82
+ result.add(next);
83
+ queue.push(next);
89
84
  }
90
- inputs[key] = parsed;
91
- } catch {
92
- if (value.length > MAX_INPUT_STRING_LENGTH) {
93
- console.warn(
94
- `⚠️ Input "${key}" exceeds maximum length of ${MAX_INPUT_STRING_LENGTH} characters`
95
- );
96
- continue;
97
- }
98
- if (value.includes('\u0000')) {
99
- console.warn(`⚠️ Input "${key}" contains invalid null characters`);
100
- continue;
101
- }
102
- // Check if it looks like malformed JSON (starts with { or [)
103
- if ((value.startsWith('{') || value.startsWith('[')) && value.length > 1) {
104
- console.warn(
105
- `⚠️ Input "${key}" looks like JSON but failed to parse. Check for syntax errors.`
106
- );
107
- console.warn(` Value: ${value.slice(0, 50)}${value.length > 50 ? '...' : ''}`);
108
- }
109
- // Fall back to string value
110
- inputs[key] = value;
111
85
  }
112
86
  }
113
- return inputs;
87
+
88
+ return Array.from(result);
114
89
  };
115
90
 
116
- // ===== keystone init =====
91
+ // ===== keystone watch =====
117
92
  program
118
- .command('init')
119
- .description('Initialize a new Keystone project')
120
- .action(() => {
121
- console.log('🏛️ Initializing Keystone project...\n');
122
-
123
- // Create directories
124
- const dirs = ['.keystone', '.keystone/workflows', '.keystone/workflows/agents'];
125
- for (const dir of dirs) {
126
- if (!existsSync(dir)) {
127
- mkdirSync(dir, { recursive: true });
128
- console.log(`✓ Created ${dir}/`);
129
- } else {
130
- console.log(`⊘ ${dir}/ already exists`);
131
- }
132
- }
93
+ .command('watch')
94
+ .description('Watch a workflow and re-run on changes')
95
+ .argument('<workflow>', 'Workflow name or path to workflow file')
96
+ .option('-i, --input <key=value...>', 'Input values')
97
+ .option('--debug', 'Enable interactive debug mode on failure')
98
+ .option('--events', 'Emit structured JSON events (NDJSON) to stdout')
99
+ .option('--debounce <ms>', 'Debounce delay in milliseconds', '200')
100
+ .action(async (workflowPathArg, options) => {
101
+ const inputs = parseInputs(options.input);
102
+ const eventsEnabled = !!options.events;
103
+ const logger = eventsEnabled ? new SilentLogger() : new ConsoleLogger();
104
+ const onEvent = eventsEnabled
105
+ ? (event: unknown) => {
106
+ process.stdout.write(`${JSON.stringify(event)}\n`);
107
+ }
108
+ : undefined;
109
+ const debounceMs = Number.parseInt(options.debounce, 10);
133
110
 
134
- // Create default config
135
- const configPath = '.keystone/config.yaml';
136
- if (!existsSync(configPath)) {
137
- const defaultConfig = `# Keystone Configuration
138
- default_provider: openai
139
-
140
- providers:
141
- openai:
142
- type: openai
143
- base_url: https://api.openai.com/v1
144
- api_key_env: OPENAI_API_KEY
145
- default_model: gpt-4o
146
- anthropic:
147
- type: anthropic
148
- base_url: https://api.anthropic.com/v1
149
- api_key_env: ANTHROPIC_API_KEY
150
- default_model: claude-3-5-sonnet-20240620
151
- groq:
152
- type: openai
153
- base_url: https://api.groq.com/openai/v1
154
- api_key_env: GROQ_API_KEY
155
- default_model: llama-3.3-70b-versatile
156
-
157
- model_mappings:
158
- "gpt-*": openai
159
- "claude-*": anthropic
160
- "o1-*": openai
161
- "llama-*": groq
162
-
163
- # mcp_servers:
164
- # filesystem:
165
- # command: npx
166
- # args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
167
-
168
- # engines:
169
- # allowlist:
170
- # codex:
171
- # command: codex
172
- # version: "1.2.3"
173
- # versionArgs: ["--version"]
174
-
175
- storage:
176
- retention_days: 30
177
- `;
178
- writeFileSync(configPath, defaultConfig);
179
- console.log(`✓ Created ${configPath}`);
180
- } else {
181
- console.log(`⊘ ${configPath} already exists`);
111
+ if (!Number.isFinite(debounceMs) || debounceMs < 0) {
112
+ console.error('✗ debounce must be a non-negative integer');
113
+ process.exit(1);
182
114
  }
183
115
 
184
- // Create example .env
185
- const envPath = '.env';
186
- if (!existsSync(envPath)) {
187
- const envTemplate = `# API Keys and Secrets
188
- # OPENAI_API_KEY=sk-...
189
- # ANTHROPIC_API_KEY=sk-ant-...
190
- `;
191
- writeFileSync(envPath, envTemplate);
192
- console.log(`✓ Created ${envPath}`);
193
- } else {
194
- console.log(`⊘ ${envPath} already exists`);
116
+ let resolvedPath: string;
117
+ try {
118
+ resolvedPath = WorkflowRegistry.resolvePath(workflowPathArg);
119
+ } catch (error) {
120
+ console.error(
121
+ '✗ Failed to resolve workflow:',
122
+ error instanceof Error ? error.message : error
123
+ );
124
+ process.exit(1);
195
125
  }
196
126
 
197
- // Seed default workflows and agents
198
- const seeds = [
199
- {
200
- path: '.keystone/workflows/scaffold-feature.yaml',
201
- content: scaffoldWorkflow,
202
- },
203
- {
204
- path: '.keystone/workflows/scaffold-plan.yaml',
205
- content: scaffoldPlanWorkflow,
206
- },
207
- {
208
- path: '.keystone/workflows/scaffold-generate.yaml',
209
- content: scaffoldGenerateWorkflow,
210
- },
211
- {
212
- path: '.keystone/workflows/decompose-problem.yaml',
213
- content: decomposeWorkflow,
214
- },
215
- {
216
- path: '.keystone/workflows/decompose-research.yaml',
217
- content: decomposeResearchWorkflow,
218
- },
219
- {
220
- path: '.keystone/workflows/decompose-implement.yaml',
221
- content: decomposeImplementWorkflow,
222
- },
223
- {
224
- path: '.keystone/workflows/decompose-review.yaml',
225
- content: decomposeReviewWorkflow,
226
- },
227
- {
228
- path: '.keystone/workflows/agents/keystone-architect.md',
229
- content: architectAgent,
230
- },
231
- {
232
- path: '.keystone/workflows/agents/general.md',
233
- content: generalAgent,
234
- },
235
- {
236
- path: '.keystone/workflows/agents/explore.md',
237
- content: exploreAgent,
238
- },
239
- {
240
- path: '.keystone/workflows/agents/software-engineer.md',
241
- content: softwareEngineerAgent,
242
- },
243
- {
244
- path: '.keystone/workflows/agents/summarizer.md',
245
- content: summarizerAgent,
246
- },
247
- {
248
- path: '.keystone/workflows/dev.yaml',
249
- content: devWorkflow,
250
- },
251
- {
252
- path: '.keystone/workflows/agents/tester.md',
253
- content: testerAgent,
254
- },
255
- ];
127
+ const watchers = new Map<string, FSWatcher>();
128
+ const warned = new Set<string>();
129
+ let running = false;
130
+ let rerunQueued = false;
131
+ let debounceTimer: NodeJS.Timeout | undefined;
256
132
 
257
- for (const seed of seeds) {
258
- if (!existsSync(seed.path)) {
259
- writeFileSync(seed.path, seed.content);
260
- console.log(`✓ Seeded ${seed.path}`);
261
- } else {
262
- console.log(`⊘ ${seed.path} already exists`);
133
+ const logInfo = (message: string) => {
134
+ if (!eventsEnabled) {
135
+ console.log(message);
263
136
  }
264
- }
137
+ };
265
138
 
266
- console.log('\n✨ Keystone project initialized!');
267
- console.log('\nNext steps:');
268
- console.log(' 1. Add your API keys to .env');
269
- console.log(' 2. Create a workflow in .keystone/workflows/');
270
- console.log(' 3. Run: keystone run <workflow>');
271
- });
139
+ const logWarn = (message: string) => {
140
+ if (!eventsEnabled) {
141
+ console.warn(message);
142
+ }
143
+ };
272
144
 
273
- // ===== keystone validate =====
274
- program
275
- .command('validate')
276
- .description('Validate workflow files')
277
- .argument('[path]', 'Workflow file or directory to validate (default: .keystone/workflows/)')
278
- .option('--strict', 'Enable strict validation (schemas, enums)')
279
- .option('--explain', 'Show detailed error context with suggestions')
280
- .action(async (pathArg, options) => {
281
- const path = pathArg || '.keystone/workflows/';
145
+ const normalizePath = (filePath: string) => resolve(filePath);
282
146
 
283
- try {
284
- let files: string[] = [];
285
- if (existsSync(path) && (path.endsWith('.yaml') || path.endsWith('.yml'))) {
286
- files = [path];
287
- } else if (existsSync(path)) {
288
- const glob = new Bun.Glob('**/*.{yaml,yml}');
289
- for await (const file of glob.scan(path)) {
290
- files.push(join(path, file));
147
+ const scheduleRun = (reason?: string) => {
148
+ if (debounceTimer) {
149
+ clearTimeout(debounceTimer);
150
+ }
151
+ debounceTimer = setTimeout(() => {
152
+ debounceTimer = undefined;
153
+ if (reason && !eventsEnabled) {
154
+ console.log(`Change detected in ${reason}. Rerunning...`);
291
155
  }
292
- } else {
293
- try {
294
- const resolved = WorkflowRegistry.resolvePath(path);
295
- files = [resolved];
296
- } catch {
297
- console.error(`✗ Path not found: ${path}`);
298
- process.exit(1);
156
+ void runWorkflow();
157
+ }, debounceMs);
158
+ };
159
+
160
+ const ensureWatcher = (filePath: string) => {
161
+ if (watchers.has(filePath)) return;
162
+ if (!existsSync(filePath)) {
163
+ if (!warned.has(filePath)) {
164
+ warned.add(filePath);
165
+ logWarn(`⚠️ Watch skipped (path not found): ${filePath}`);
166
+ }
167
+ return;
168
+ }
169
+ try {
170
+ const watcher = watch(filePath, () => scheduleRun(filePath));
171
+ watchers.set(filePath, watcher);
172
+ } catch (error) {
173
+ if (!warned.has(filePath)) {
174
+ warned.add(filePath);
175
+ logWarn(
176
+ `⚠️ Failed to watch ${filePath}: ${error instanceof Error ? error.message : String(error)}`
177
+ );
299
178
  }
300
179
  }
180
+ };
301
181
 
302
- if (files.length === 0) {
303
- console.log('⊘ No workflow files found to validate.');
304
- return;
182
+ const updateWatchers = (paths: Set<string>) => {
183
+ for (const existing of Array.from(watchers.keys())) {
184
+ if (!paths.has(existing)) {
185
+ watchers.get(existing)?.close();
186
+ watchers.delete(existing);
187
+ }
188
+ }
189
+
190
+ for (const filePath of paths) {
191
+ ensureWatcher(filePath);
305
192
  }
306
193
 
307
- console.log(`🔍 Validating ${files.length} workflow(s)...\n`);
194
+ logInfo(`Watching ${paths.size} file(s).`);
195
+ };
308
196
 
309
- let successCount = 0;
310
- let failCount = 0;
197
+ const collectWatchPaths = (
198
+ workflowPath: string,
199
+ workflow: Workflow,
200
+ visited: Set<string> = new Set()
201
+ ): Set<string> => {
202
+ const normalizedPath = normalizePath(workflowPath);
203
+ if (visited.has(normalizedPath)) return new Set();
204
+ visited.add(normalizedPath);
205
+
206
+ const watchPaths = new Set<string>([normalizedPath]);
207
+ const baseDir = dirname(workflowPath);
208
+ const allSteps = [...workflow.steps, ...(workflow.errors || []), ...(workflow.finally || [])];
209
+
210
+ for (const step of allSteps) {
211
+ if (step.type === 'file' && step.op === 'read') {
212
+ if (ExpressionEvaluator.hasExpression(step.path)) {
213
+ const warningKey = `${workflowPath}:${step.id}:file`;
214
+ if (!warned.has(warningKey)) {
215
+ warned.add(warningKey);
216
+ logWarn(`⚠️ Watch skipped for dynamic file path in step "${step.id}".`);
217
+ }
218
+ continue;
219
+ }
220
+ watchPaths.add(normalizePath(resolve(baseDir, step.path)));
221
+ }
311
222
 
312
- for (const file of files) {
313
- try {
314
- const workflow = WorkflowParser.loadWorkflow(file);
315
- if (options.strict) {
316
- const source = readFileSync(file, 'utf-8');
317
- WorkflowParser.validateStrict(workflow, source);
223
+ if (step.type === 'workflow') {
224
+ if (ExpressionEvaluator.hasExpression(step.path)) {
225
+ const warningKey = `${workflowPath}:${step.id}:workflow`;
226
+ if (!warned.has(warningKey)) {
227
+ warned.add(warningKey);
228
+ logWarn(`⚠️ Watch skipped for dynamic workflow path in step "${step.id}".`);
229
+ }
230
+ continue;
318
231
  }
319
- console.log(` ✓ ${file.padEnd(40)} ${workflow.name} (${workflow.steps.length} steps)`);
320
- successCount++;
321
- } catch (error) {
322
- if (options.explain) {
323
- const { readFileSync } = await import('node:fs');
324
- const { formatYamlError, renderError, formatError } = await import(
325
- './utils/error-renderer.ts'
326
- );
327
- try {
328
- const source = readFileSync(file, 'utf-8');
329
- const formatted = formatYamlError(error as Error, source, file);
330
- console.error(renderError({ message: formatted.summary, source, filePath: file }));
331
- } catch {
332
- console.error(
333
- renderError({
334
- message: error instanceof Error ? error.message : String(error),
335
- filePath: file,
336
- })
232
+ try {
233
+ const childPath = WorkflowRegistry.resolvePath(step.path, baseDir);
234
+ const childWorkflow = WorkflowParser.loadWorkflow(childPath);
235
+ for (const child of collectWatchPaths(childPath, childWorkflow, visited)) {
236
+ watchPaths.add(child);
237
+ }
238
+ } catch (error) {
239
+ const warningKey = `${workflowPath}:${step.id}:workflow-load`;
240
+ if (!warned.has(warningKey)) {
241
+ warned.add(warningKey);
242
+ logWarn(
243
+ `⚠️ Failed to load sub-workflow for step "${step.id}": ${
244
+ error instanceof Error ? error.message : String(error)
245
+ }`
337
246
  );
338
247
  }
339
- } else {
340
- console.error(
341
- ` ✗ ${file.padEnd(40)} ${error instanceof Error ? error.message : String(error)}`
342
- );
343
248
  }
344
- failCount++;
345
249
  }
346
250
  }
347
251
 
348
- console.log(`\nSummary: ${successCount} passed, ${failCount} failed.`);
349
- if (failCount > 0) {
350
- process.exit(1);
351
- }
352
- } catch (error) {
353
- console.error('✗ Validation failed:', error instanceof Error ? error.message : error);
354
- process.exit(1);
355
- }
356
- });
252
+ return watchPaths;
253
+ };
357
254
 
358
- // ===== keystone graph =====
359
- program
360
- .command('graph')
361
- .description('Visualize a workflow as a Mermaid.js graph')
362
- .argument('<workflow>', 'Workflow name or path to workflow file')
363
- .action(async (workflowPath) => {
364
- try {
365
- const resolvedPath = WorkflowRegistry.resolvePath(workflowPath);
366
- const workflow = WorkflowParser.loadWorkflow(resolvedPath);
367
- const ascii = renderWorkflowAsAscii(workflow);
368
- if (ascii) {
369
- console.log(`\n${ascii}\n`);
370
- } else {
371
- const mermaid = generateMermaidGraph(workflow);
372
- console.log('\n```mermaid');
373
- console.log(mermaid);
374
- console.log('```\n');
255
+ const runWorkflow = async () => {
256
+ if (running) {
257
+ rerunQueued = true;
258
+ return;
375
259
  }
376
- } catch (error) {
377
- console.error('✗ Failed to generate graph:', error instanceof Error ? error.message : error);
378
- process.exit(1);
379
- }
380
- });
381
-
382
- // ===== keystone run =====
383
- program
384
- .command('run')
385
- .description('Execute a workflow')
386
- .argument('<workflow>', 'Workflow name or path to workflow file')
387
- .option('-i, --input <key=value...>', 'Input values')
388
- .option('--dry-run', 'Show what would be executed without actually running it')
389
- .option('--debug', 'Enable interactive debug mode on failure')
390
- .option('--no-dedup', 'Disable idempotency/deduplication')
391
- .option('--resume', 'Resume the last run of this workflow if it failed or was paused')
392
- .option('--explain', 'Show detailed error context with suggestions on failure')
393
- .action(async (workflowPathArg, options) => {
394
- const inputs = parseInputs(options.input);
395
- let resolvedPath: string | undefined;
260
+ running = true;
396
261
 
397
- // Load and validate workflow
398
- try {
399
- resolvedPath = WorkflowRegistry.resolvePath(workflowPathArg);
400
- const workflow = WorkflowParser.loadWorkflow(resolvedPath);
401
-
402
- // Import WorkflowRunner dynamically
403
- const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
404
- const logger = new ConsoleLogger();
405
-
406
- let resumeRunId: string | undefined;
407
-
408
- // Handle auto-resume
409
- if (options.resume) {
410
- const db = new WorkflowDb();
411
- const lastRun = await db.getLastRun(workflow.name);
412
- db.close();
262
+ try {
263
+ const workflow = WorkflowParser.loadWorkflow(resolvedPath);
264
+ const watchPaths = collectWatchPaths(resolvedPath, workflow);
265
+ updateWatchers(watchPaths);
266
+
267
+ const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
268
+ const runner = new WorkflowRunner(workflow, {
269
+ inputs,
270
+ workflowDir: dirname(resolvedPath),
271
+ debug: !!options.debug,
272
+ logger,
273
+ onEvent,
274
+ });
413
275
 
414
- if (lastRun) {
415
- if (
416
- lastRun.status === 'failed' ||
417
- lastRun.status === 'paused' ||
418
- lastRun.status === 'running'
419
- ) {
420
- resumeRunId = lastRun.id;
421
- console.log(
422
- `Resuming run ${lastRun.id} (status: ${lastRun.status}) from ${new Date(
423
- lastRun.started_at
424
- ).toLocaleString()}`
425
- );
426
- } else {
427
- console.log(`Last run ${lastRun.id} completed successfully. Starting new run.`);
428
- }
429
- } else {
430
- console.log('No previous run found. Starting new run.');
276
+ const outputs = await runner.run();
277
+ if (!eventsEnabled && Object.keys(outputs).length > 0) {
278
+ console.log('Outputs:');
279
+ console.log(JSON.stringify(runner.redact(outputs), null, 2));
280
+ }
281
+ } catch (error) {
282
+ console.error('✗ Watch run failed:', error instanceof Error ? error.message : error);
283
+ } finally {
284
+ running = false;
285
+ if (rerunQueued) {
286
+ rerunQueued = false;
287
+ scheduleRun();
431
288
  }
432
289
  }
290
+ };
433
291
 
434
- const runner = new WorkflowRunner(workflow, {
435
- inputs: resumeRunId ? undefined : inputs,
436
- resumeInputs: resumeRunId ? inputs : undefined,
437
- workflowDir: dirname(resolvedPath),
438
- dryRun: !!options.dryRun,
439
- debug: !!options.debug,
440
- dedup: options.dedup,
441
- resumeRunId,
442
- logger,
443
- });
444
-
445
- const outputs = await runner.run();
292
+ updateWatchers(new Set([normalizePath(resolvedPath)]));
293
+ logInfo(`Watching workflow: ${resolvedPath}`);
294
+ scheduleRun('initial');
446
295
 
447
- if (Object.keys(outputs).length > 0) {
448
- console.log('Outputs:');
449
- console.log(JSON.stringify(runner.redact(outputs), null, 2));
296
+ process.on('SIGINT', () => {
297
+ for (const watcher of watchers.values()) {
298
+ watcher.close();
450
299
  }
300
+ logInfo('\nStopping watch.');
451
301
  process.exit(0);
452
- } catch (error) {
453
- if (options.explain) {
454
- const message = error instanceof Error ? error.message : String(error);
455
- try {
456
- const { readFileSync } = await import('node:fs');
457
- const { renderError } = await import('./utils/error-renderer.ts');
458
- const source = resolvedPath ? readFileSync(resolvedPath, 'utf-8') : undefined;
459
- console.error(
460
- renderError({
461
- message,
462
- source,
463
- filePath: resolvedPath,
464
- })
465
- );
466
- } catch {
467
- console.error('✗ Failed to execute workflow:', message);
468
- }
469
- } else {
470
- console.error(
471
- '✗ Failed to execute workflow:',
472
- error instanceof Error ? error.message : error
473
- );
474
- }
475
- process.exit(1);
476
- }
302
+ });
477
303
  });
478
304
 
479
305
  // ===== keystone test =====
@@ -516,7 +342,7 @@ program
516
342
  const workflowPath = WorkflowRegistry.resolvePath(testDef.workflow);
517
343
  const workflow = WorkflowParser.loadWorkflow(workflowPath);
518
344
 
519
- const harness = new TestHarness(workflow, testDef.fixture);
345
+ const harness = new TestHarness(workflow, testDef.fixture, testDef.options);
520
346
  const result = await harness.run();
521
347
 
522
348
  if (!testDef.snapshot || options.update) {
@@ -622,9 +448,11 @@ program
622
448
  .argument('<run_id>', 'Run ID to resume')
623
449
  .option('-w, --workflow <path>', 'Path to workflow file (auto-detected if not specified)')
624
450
  .option('-i, --input <key=value...>', 'Input values for resume')
451
+ .option('--events', 'Emit structured JSON events (NDJSON) to stdout')
625
452
  .action(async (runId, options) => {
626
453
  try {
627
454
  const db = new WorkflowDb();
455
+ const eventsEnabled = !!options.events;
628
456
 
629
457
  // Load run from database to get workflow name
630
458
  const run = await db.getRun(runId);
@@ -635,7 +463,9 @@ program
635
463
  process.exit(1);
636
464
  }
637
465
 
638
- console.log(`Found run: ${run.workflow_name} (status: ${run.status})`);
466
+ if (!eventsEnabled) {
467
+ console.log(`Found run: ${run.workflow_name} (status: ${run.status})`);
468
+ }
639
469
 
640
470
  // Determine workflow file path
641
471
  let workflowPath = options.workflow;
@@ -652,7 +482,9 @@ program
652
482
  }
653
483
  }
654
484
 
655
- console.log(`Loading workflow from: ${workflowPath}\n`);
485
+ if (!eventsEnabled) {
486
+ console.log(`Loading workflow from: ${workflowPath}\n`);
487
+ }
656
488
 
657
489
  // Close DB before loading workflow (will be reopened by runner)
658
490
  db.close();
@@ -662,18 +494,24 @@ program
662
494
 
663
495
  // Import WorkflowRunner dynamically
664
496
  const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
665
- const logger = new ConsoleLogger();
497
+ const logger = eventsEnabled ? new SilentLogger() : new ConsoleLogger();
498
+ const onEvent = eventsEnabled
499
+ ? (event: unknown) => {
500
+ process.stdout.write(`${JSON.stringify(event)}\n`);
501
+ }
502
+ : undefined;
666
503
  const inputs = parseInputs(options.input);
667
504
  const runner = new WorkflowRunner(workflow, {
668
505
  resumeRunId: runId,
669
506
  resumeInputs: inputs,
670
507
  workflowDir: dirname(workflowPath),
671
508
  logger,
509
+ onEvent,
672
510
  });
673
511
 
674
512
  const outputs = await runner.run();
675
513
 
676
- if (Object.keys(outputs).length > 0) {
514
+ if (!eventsEnabled && Object.keys(outputs).length > 0) {
677
515
  console.log('Outputs:');
678
516
  console.log(JSON.stringify(runner.redact(outputs), null, 2));
679
517
  }
@@ -684,6 +522,94 @@ program
684
522
  }
685
523
  });
686
524
 
525
+ // ===== keystone rerun =====
526
+ program
527
+ .command('rerun')
528
+ .description('Rerun a workflow from a specific step (invalidates downstream steps)')
529
+ .argument('<workflow>', 'Workflow name or path to workflow file')
530
+ .requiredOption('--from <step_id>', 'Step ID to rerun (downstream steps will be invalidated)')
531
+ .option('-r, --run <run_id>', 'Run ID to rerun (defaults to last run of the workflow)')
532
+ .option('-i, --input <key=value...>', 'Input values for rerun')
533
+ .option('--events', 'Emit structured JSON events (NDJSON) to stdout')
534
+ .action(async (workflowPathArg, options) => {
535
+ let db: WorkflowDb | undefined;
536
+ try {
537
+ const resolvedPath = WorkflowRegistry.resolvePath(workflowPathArg);
538
+ const workflow = WorkflowParser.loadWorkflow(resolvedPath);
539
+ const inputs = parseInputs(options.input);
540
+ const eventsEnabled = !!options.events;
541
+
542
+ db = new WorkflowDb();
543
+ const runId =
544
+ options.run ||
545
+ (await db.getLastRun(workflow.name))?.id ||
546
+ ((): never => {
547
+ throw new Error(`No runs found for workflow "${workflow.name}"`);
548
+ })();
549
+
550
+ const run = await db.getRun(runId);
551
+ if (!run) {
552
+ throw new Error(`Run not found: ${runId}`);
553
+ }
554
+
555
+ if (run.workflow_name !== workflow.name) {
556
+ console.warn(
557
+ `⚠️ Run ${runId} is for workflow "${run.workflow_name}", but you provided "${workflow.name}".`
558
+ );
559
+ }
560
+
561
+ if (run.status === 'running') {
562
+ console.warn('⚠️ Rerunning a run marked as running. Ensure no other instances are active.');
563
+ }
564
+
565
+ const stepIds = collectDownstreamSteps(workflow, options.from);
566
+ const clearedSteps = await db.clearStepExecutions(runId, stepIds);
567
+ const clearedIdempotency = await db.clearIdempotencyRecordsForSteps(runId, stepIds);
568
+ const clearedTimers = await db.clearTimersForSteps(runId, stepIds);
569
+ const clearedCompensations = await db.clearCompensationsForSteps(runId, stepIds);
570
+
571
+ await db.updateRunStatus(runId, 'paused');
572
+ db.close();
573
+ db = undefined;
574
+
575
+ if (!eventsEnabled) {
576
+ console.log(
577
+ `Cleared ${clearedSteps} step execution(s), ${clearedIdempotency} idempotency record(s), ${clearedTimers} timer(s), ${clearedCompensations} compensation(s).`
578
+ );
579
+ console.log(`Resuming run ${runId} from step ${options.from}...\n`);
580
+ }
581
+
582
+ const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
583
+ const logger = eventsEnabled ? new SilentLogger() : new ConsoleLogger();
584
+ const onEvent = eventsEnabled
585
+ ? (event: unknown) => {
586
+ process.stdout.write(`${JSON.stringify(event)}\n`);
587
+ }
588
+ : undefined;
589
+ const runner = new WorkflowRunner(workflow, {
590
+ resumeRunId: runId,
591
+ resumeInputs: inputs,
592
+ workflowDir: dirname(resolvedPath),
593
+ logger,
594
+ allowSuccessResume: true,
595
+ onEvent,
596
+ });
597
+
598
+ const outputs = await runner.run();
599
+
600
+ if (!eventsEnabled && Object.keys(outputs).length > 0) {
601
+ console.log('Outputs:');
602
+ console.log(JSON.stringify(runner.redact(outputs), null, 2));
603
+ }
604
+ process.exit(0);
605
+ } catch (error) {
606
+ console.error('✗ Failed to rerun workflow:', error instanceof Error ? error.message : error);
607
+ process.exit(1);
608
+ } finally {
609
+ db?.close();
610
+ }
611
+ });
612
+
687
613
  // ===== keystone history =====
688
614
  program
689
615
  .command('history')
@@ -1881,9 +1807,11 @@ _keystone() {
1881
1807
  commands=(
1882
1808
  'init:Initialize a new Keystone project'
1883
1809
  'validate:Validate workflow files'
1810
+ 'lint:Lint workflow files'
1884
1811
  'graph:Visualize a workflow as a Mermaid.js graph'
1885
1812
  'run:Execute a workflow'
1886
1813
  'resume:Resume a paused or failed workflow run'
1814
+ 'rerun:Rerun a workflow from a specific step'
1887
1815
  'workflows:List available workflows'
1888
1816
  'history:List recent workflow runs'
1889
1817
  'logs:Show logs for a workflow run'
@@ -1909,11 +1837,17 @@ _keystone() {
1909
1837
  validate)
1910
1838
  _arguments ':path:_files'
1911
1839
  ;;
1840
+ lint)
1841
+ _arguments ':path:_files'
1842
+ ;;
1912
1843
  resume)
1913
1844
  _arguments \\
1914
1845
  '(-i --input)'{-i,--input}'[Input values]:key=value' \\
1915
1846
  ':run_id:__keystone_runs'
1916
1847
  ;;
1848
+ rerun)
1849
+ _arguments ':workflow:__keystone_workflows'
1850
+ ;;
1917
1851
  logs)
1918
1852
  _arguments ':run_id:__keystone_runs'
1919
1853
  ;;
@@ -1949,10 +1883,10 @@ __keystone_runs() {
1949
1883
  COMPREPLY=()
1950
1884
  cur="\${COMP_WORDS[COMP_CWORD]}"
1951
1885
  prev="\${COMP_WORDS[COMP_CWORD - 1]}"
1952
- opts="init validate graph run resume workflows history logs prune ui mcp config auth completion"
1886
+ opts="init validate lint graph run watch resume rerun workflows history logs prune ui mcp config auth completion"
1953
1887
 
1954
1888
  case "\${prev}" in
1955
- run|graph)
1889
+ run|graph|rerun)
1956
1890
  local workflows=$(keystone _list-workflows 2>/dev/null)
1957
1891
  COMPREPLY=( $(compgen -W "\${workflows}" -- \${cur}) )
1958
1892
  return 0