From b89e99683a64c4317530207025570128ec01831d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 6 May 2026 22:33:43 +0200 Subject: [PATCH] feat: improve workflow generation CLI feedback --- src/local/entrypoint.test.ts | 6 ++- src/local/entrypoint.ts | 8 ++- src/product/generation/pipeline.test.ts | 52 +++++++++++++++++-- src/product/generation/pipeline.ts | 23 +++++++- src/product/generation/template-renderer.ts | 5 +- .../workforce-persona-writer.test.ts | 3 +- .../generation/workforce-persona-writer.ts | 3 +- src/surfaces/cli/commands/cli-main.test.ts | 31 +++++++++++ src/surfaces/cli/commands/cli-main.ts | 4 ++ .../cli/flows/power-user-parser.test.ts | 31 +++++++++++ src/surfaces/cli/flows/power-user-parser.ts | 10 +++- 11 files changed, 164 insertions(+), 12 deletions(-) diff --git a/src/local/entrypoint.test.ts b/src/local/entrypoint.test.ts index 9de2107f..cd6861e4 100644 --- a/src/local/entrypoint.test.ts +++ b/src/local/entrypoint.test.ts @@ -1202,7 +1202,11 @@ describe('runLocal', () => { expect(result.ok).toBe(true); expect(progress).toEqual([ - 'ricky is writing the workflow...', + 'Reading spec and preparing local context...', + 'Spec intake routed to generate...', + 'Selecting workflow pattern, agents, and validation gates...', + 'Rendering workflow artifact...', + expect.stringMatching(/^Writing workflow artifact to workflows\/generated\/.+\.ts\.\.\.$/), 'Running workflow...', ]); }); diff --git a/src/local/entrypoint.ts b/src/local/entrypoint.ts index c01867e9..f61f1f2e 100644 --- a/src/local/entrypoint.ts +++ b/src/local/entrypoint.ts @@ -950,6 +950,7 @@ export function createLocalExecutor(options: LocalExecutorOptions = {}): LocalEx logs.push(`[local] spec path: ${request.specPath}`); } + onProgress?.('Reading spec and preparing local context...'); if (request.mode === 'cloud') { warnings.push( 'Cloud mode was requested but this is the local/BYOH entrypoint. ' + @@ -961,6 +962,7 @@ export function createLocalExecutor(options: LocalExecutorOptions = {}): LocalEx const intakeResult = intake(toRawSpecPayload(request)); logs.push(`[local] spec intake route: ${intakeResult.routing?.target ?? 'none'}`); + onProgress?.(`Spec intake routed to ${intakeResult.routing?.target ?? 'clarify'}...`); warnings.push(...intakeResult.parseWarnings); warnings.push(...intakeResult.validationIssues.map((issue) => `${issue.field}: ${issue.message}`)); @@ -999,7 +1001,10 @@ export function createLocalExecutor(options: LocalExecutorOptions = {}): LocalEx refine: request.refine, ...(workforcePersonaWriter ? { workforcePersonaWriter } : {}), }; - onProgress?.('ricky is writing the workflow...'); + onProgress?.('Selecting workflow pattern, agents, and validation gates...'); + onProgress?.(generationInput.workforcePersonaWriter + ? 'Authoring workflow with Workforce persona...' + : 'Rendering workflow artifact...'); generationResult = generationInput.workforcePersonaWriter ? await generateWithWorkforcePersona(generationInput) : generate(generationInput); @@ -1034,6 +1039,7 @@ export function createLocalExecutor(options: LocalExecutorOptions = {}): LocalEx return { ok: false, artifacts, logs, warnings, nextActions, ...stageResponse(includeStageContract, generationStage, undefined, 1) }; } + onProgress?.(`Writing workflow artifact to ${artifact.artifactPath}...`); await artifactWriter.writeArtifact(artifact.artifactPath, artifact.content, cwd); if (options.persistGenerationMetadataArtifacts === true) { await writeGenerationMetadataArtifacts(generationResult, artifactWriter, cwd); diff --git a/src/product/generation/pipeline.test.ts b/src/product/generation/pipeline.test.ts index 4865330f..15c5f9f0 100644 --- a/src/product/generation/pipeline.test.ts +++ b/src/product/generation/pipeline.test.ts @@ -69,7 +69,8 @@ describe('workflow generation pipeline', () => { expect(artifact.content).toContain('.agent("impl-tests-codex"'); expect(artifact.content).toContain('.agent("validator-claude"'); expect(artifact.content).toContain('80-to-100 fix loop'); - expect(artifact.content).toContain('deterministic sanity gate using grep, rg, or an equivalent assertion'); + expect(artifact.content).toContain('deterministic sanity gate using POSIX grep, git grep, or an equivalent assertion'); + expect(artifact.content).toContain('If using rg, guard it with command -v rg'); expect(artifact.content).toContain('Generated workflow quality'); expect(artifact.content).toContain('Keep each agent step bounded to one coherent slice'); expect(result.toolSelection.selections).toEqual( @@ -608,7 +609,7 @@ describe('workflow generation pipeline', () => { ); }); - it('accepts ripgrep as an equivalent deterministic sanity gate', () => { + it('rejects ripgrep gates without real fallback control flow because rg may be absent', () => { const implementationSpec = spec({ description: 'Implement local workflow generation checks with resilient sanity validation.', targetFiles: ['src/product/generation/pipeline.ts'], @@ -629,7 +630,7 @@ describe('workflow generation pipeline', () => { gates: gatesWithoutGrep.map((gate) => gate.name === 'post-implementation-file-gate' ? { ...gate, - command: "test -f src/product/generation/pipeline.ts && rg -e 'export|function|class' src/product/generation/pipeline.ts", + command: "command -v rg >/dev/null 2>&1 && rg -e 'export|function|class' src/product/generation/pipeline.ts && grep -Eq 'pipeline' README.md", } : gate), }; @@ -641,6 +642,51 @@ describe('workflow generation pipeline', () => { implementationSpec, ); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'RIPGREP_REQUIRES_FALLBACK' }), + ]), + ); + }); + + it('accepts ripgrep sanity gates when they include a grep fallback', () => { + const implementationSpec = spec({ + description: 'Implement local workflow generation checks with resilient sanity validation.', + targetFiles: ['src/product/generation/pipeline.ts'], + }); + const result = generate({ + spec: implementationSpec, + artifactPath: 'workflows/generated/resilient-sanity.ts', + }); + const base = artifact(result); + const gatesWithoutGrep = base.gates.map((gate) => ({ + ...gate, + command: gate.command + .replace(/\bgit\s+grep\b/g, 'printf') + .replace(/\bgrep\b/g, 'printf'), + })); + const rgArtifact = { + ...base, + gates: gatesWithoutGrep.map((gate) => gate.name === 'post-implementation-file-gate' + ? { + ...gate, + command: "test -f src/product/generation/pipeline.ts && { if command -v rg >/dev/null 2>&1; then rg -e 'export|function|class' src/product/generation/pipeline.ts; else grep -Eq 'export|function|class' src/product/generation/pipeline.ts; fi; }", + } + : gate), + }; + + const validation = validateGeneratedArtifact( + rgArtifact, + result.patternDecision, + result.skillContext, + implementationSpec, + ); + + expect(validation.issues).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'RIPGREP_REQUIRES_FALLBACK' }), + ]), + ); expect(validation.issues).not.toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'GREP_GATE_MISSING' }), diff --git a/src/product/generation/pipeline.ts b/src/product/generation/pipeline.ts index 70b3f43f..11cdab54 100644 --- a/src/product/generation/pipeline.ts +++ b/src/product/generation/pipeline.ts @@ -222,7 +222,15 @@ export function validateGeneratedArtifact( issues.push(blockingIssue( 'validation', 'GREP_GATE_MISSING', - 'Rendered workflow has no deterministic sanity gate such as grep, rg, or an equivalent assertion.', + 'Rendered workflow has no deterministic sanity gate such as grep, git grep, or an equivalent assertion.', + )); + } + const unguardedRipgrepGates = artifact.gates.filter((gate) => usesRipgrep(gate.command) && !hasRipgrepFallback(gate.command)); + if (unguardedRipgrepGates.length > 0) { + issues.push(blockingIssue( + 'validation', + 'RIPGREP_REQUIRES_FALLBACK', + `Rendered workflow uses rg without a grep fallback in: ${unguardedRipgrepGates.map((gate) => gate.name).join(', ')}.`, )); } if (!/npx tsc --noEmit/.test(content)) { @@ -370,6 +378,19 @@ function isSanityGateCommand(command: string): boolean { ].some((pattern) => typeof pattern === 'boolean' ? pattern : pattern.test(normalized)); } +function usesRipgrep(command: string): boolean { + return /(?:^|[;&|()\s])rg(?:\s|$)/.test(command.replace(/\\\n/g, ' ')); +} + +function hasRipgrepFallback(command: string): boolean { + const normalized = command.replace(/\\\n/g, ' ').replace(/\s+/g, ' ').trim(); + if (!usesRipgrep(normalized)) return true; + const ifElseFallback = /\bif\b.*\b(?:command\s+-v|which)\s+rg\b.*\bthen\b.*\brg\b.*\belse\b.*\b(?:git\s+grep|grep)\b.*\bfi\b/.test(normalized); + const andOrFallback = /\b(?:command\s+-v|which)\s+rg\b.*&&.*\brg\b.*\|\|.*\b(?:git\s+grep|grep)\b/.test(normalized); + const plainOrFallback = /\brg\b.*\|\|.*\b(?:git\s+grep|grep)\b/.test(normalized); + return ifElseFallback || andOrFallback || plainOrFallback; +} + function isInlineAssertionCommand(command: string): boolean { const invokesInlineRuntime = /\b(?:node|bun)\s+(?:--input-type=module\s+)?(?:-e|--eval)\b/.test(command) || diff --git a/src/product/generation/template-renderer.ts b/src/product/generation/template-renderer.ts index d77a72db..c0f44ad6 100644 --- a/src/product/generation/template-renderer.ts +++ b/src/product/generation/template-renderer.ts @@ -614,7 +614,7 @@ Routing contract: - MCP: generated runtime agents must not use Relaycast management or messaging tools; MCP callers receive artifacts without a separate runtime management path. Verification commands: -${formatList(['file_exists gate for declared targets', 'deterministic sanity gate using grep, rg, or an equivalent assertion', 'active-reference gate for deleted manifest paths', 'npx tsc --noEmit', deriveTestCommand(spec), 'git diff gate comparing git diff --name-status against the declared change inventory and requiring a non-empty diff', 'PR URL or explicit result summary'])} +${formatList(['file_exists gate for declared targets', 'deterministic sanity gate using POSIX grep, git grep, or an equivalent assertion', 'active-reference gate for deleted manifest paths', 'npx tsc --noEmit', deriveTestCommand(spec), 'git diff gate comparing git diff --name-status against the declared change inventory and requiring a non-empty diff', 'PR URL or explicit result summary'])} Write ${artifactsDir}/lead-plan.md ending with GENERATION_LEAD_PLAN_READY.`)}, verification: { type: 'output_contains', value: 'GENERATION_LEAD_PLAN_READY' }, @@ -657,7 +657,8 @@ Keep execution routing explicit for local, cloud, and MCP callers. Materialize o Generated workflow quality: - Include a real deterministic sanity gate over produced files, not just prose saying one exists. -- Prefer grep, rg, git grep, or a small inline assertion command that exits non-zero when expected content/state is missing. +- Prefer POSIX grep, git grep, or a small inline assertion command that exits non-zero when expected content/state is missing. +- If using rg, guard it with command -v rg and provide a grep or git grep fallback. - For cleanup or deletion work, persist a changed-files inventory with statuses, active-reference evidence for deleted paths, and command summaries for final signoff. - For cleanup or deletion work, start from ${artifactsDir}/cleanup-candidate-prescan.txt and cite that exact path in ${artifactsDir}/cleanup-report.md so the evidence trail names its prescan input. - Keep each agent step bounded to one coherent slice. Split broad implementation or test-writing work into sequential/fan-out steps with deterministic gates between them instead of relying on a single long agent timeout.`)}, diff --git a/src/product/generation/workforce-persona-writer.test.ts b/src/product/generation/workforce-persona-writer.test.ts index 2460f2f2..84bc3bda 100644 --- a/src/product/generation/workforce-persona-writer.test.ts +++ b/src/product/generation/workforce-persona-writer.test.ts @@ -35,7 +35,8 @@ describe('workforce persona workflow writer', () => { expect(task).toContain('Matched Ricky generation skills'); expect(task).toContain('80-to-100 fix loop'); expect(task).toContain('deterministic sanity gate'); - expect(task).toContain('grep, rg, git grep'); + expect(task).toContain('POSIX grep, git grep'); + expect(task).toContain('If using rg, guard it with command -v rg'); expect(task).toContain('Keep agent steps bounded'); expect(task).toContain('Structured response contract'); expect(task).toContain('fenced ```ts artifact block plus a fenced ```json metadata block'); diff --git a/src/product/generation/workforce-persona-writer.ts b/src/product/generation/workforce-persona-writer.ts index ab6d67b1..556d6bc2 100644 --- a/src/product/generation/workforce-persona-writer.ts +++ b/src/product/generation/workforce-persona-writer.ts @@ -515,7 +515,8 @@ export function buildWorkflowPersonaTask( '- Use a dedicated workflow channel, not general.', '- Include explicit agents, step dependencies, deterministic gates, review stages, and final signoff.', '- Include an 80-to-100 fix loop: implement, validate, review, fix, final review, hard validation.', - '- Include a real deterministic sanity gate over produced files using grep, rg, git grep, or an equivalent inline assertion that exits non-zero when expected content/state is missing.', + '- Include a real deterministic sanity gate over produced files using POSIX grep, git grep, or an equivalent inline assertion that exits non-zero when expected content/state is missing.', + '- If using rg, guard it with command -v rg and provide a grep or git grep fallback because ripgrep is not guaranteed to be installed.', '- Keep agent steps bounded: split broad implementation or test-writing work into multiple sequential/fan-out steps with deterministic gates between them instead of one large step that can exhaust retries by timeout.', '- Before calling `.run(...)`, load repo-local `.env.local` and `.env` values into `process.env` without overwriting existing shell exports, so local BYOH runs inherit common project configuration. If the workflow requires named env vars, add a fast deterministic preflight/assertion that prints `MISSING_ENV_VAR: NAME` before long-running agent steps.', '- Verification must include typecheck/test commands when relevant plus git-diff evidence; diff/manifest gates must combine git diff --name-only with git ls-files --others --exclude-standard so newly-created files are visible.', diff --git a/src/surfaces/cli/commands/cli-main.test.ts b/src/surfaces/cli/commands/cli-main.test.ts index c25010b0..3bf8de4d 100644 --- a/src/surfaces/cli/commands/cli-main.test.ts +++ b/src/surfaces/cli/commands/cli-main.test.ts @@ -218,6 +218,34 @@ describe('parseArgs', () => { }); }); + it('parses the workflow one-shot command for local runs and Cloud generation', () => { + expect(parseArgs(['workflow', '--spec-file', './SPEC.md', '--run'])).toMatchObject({ + command: 'run', + surface: 'workflow', + mode: 'local', + specFile: './SPEC.md', + runRequested: true, + }); + + const cloud = parseArgs(['workflow', '--spec-file', './SPEC.md', '--mode', 'cloud']); + expect(cloud).toMatchObject({ + command: 'run', + surface: 'workflow', + mode: 'cloud', + specFile: './SPEC.md', + }); + expect(cloud).not.toHaveProperty('runRequested'); + }); + + it('surfaces invalid workflow mode values as CLI argument errors', () => { + expect(parseArgs(['workflow', '--spec-file', './SPEC.md', '--mode', 'clodu'])).toMatchObject({ + command: 'run', + surface: 'workflow', + specFile: './SPEC.md', + errors: ['--mode must be one of: local, cloud, or both.'], + }); + }); + it('does not run local or Cloud --workflow artifacts unless --run is present', () => { const localPreview = parseArgs(['local', '--workflow', 'workflows/generated/release-health.ts']); expect(localPreview).toMatchObject({ @@ -335,6 +363,9 @@ describe('renderHelp', () => { expect(helpText).toContain('Happy path:'); expect(helpText).toContain('ricky local --spec '); + expect(helpText).toContain('ricky workflow --spec-file --run'); + expect(helpText).toContain('ricky workflow --spec-file --mode cloud'); + expect(helpText).not.toContain('ricky workflow --spec-file --mode cloud --run'); expect(helpText).toContain('ricky run --background'); expect(helpText).toContain('ricky run --start-from '); expect(helpText).toContain('ricky status --run '); diff --git a/src/surfaces/cli/commands/cli-main.ts b/src/surfaces/cli/commands/cli-main.ts index 3ff9799a..7dd37bed 100644 --- a/src/surfaces/cli/commands/cli-main.ts +++ b/src/surfaces/cli/commands/cli-main.ts @@ -299,11 +299,15 @@ export function renderHelp(): string[] { 'Common commands:', ' ricky status Show local and Cloud readiness', ' ricky connect cloud Connect AgentWorkforce Cloud', + ' ricky workflow --spec-file --run Generate, then run a workflow', + ' ricky workflow --spec-file --mode cloud Generate with Cloud', ' ricky cloud --spec Generate with Cloud', ' ricky run Run attached in this terminal', '', 'Usage:', ' ricky local --spec-file --run Generate, then run locally', + ' ricky workflow --spec-file --run One command: spec -> workflow -> local run', + ' ricky workflow --spec-file --mode cloud One command: spec -> Cloud workflow artifact', ' ricky --mode Mode preset: local | cloud | both', ' ricky --mode local --spec Generate artifact only', ' ricky --mode local --spec --run Generate, then execute', diff --git a/src/surfaces/cli/flows/power-user-parser.test.ts b/src/surfaces/cli/flows/power-user-parser.test.ts index daa0b64b..c6f7ebeb 100644 --- a/src/surfaces/cli/flows/power-user-parser.test.ts +++ b/src/surfaces/cli/flows/power-user-parser.test.ts @@ -26,6 +26,37 @@ describe('power user parser defaults', () => { }); }); + it('parses the workflow one-shot command for local execution and Cloud generation', () => { + expect(parsePowerUserArgs(['workflow', '--spec-file', './SPEC.md', '--run'])).toMatchObject({ + command: 'run', + surface: 'workflow', + mode: 'local', + specFile: './SPEC.md', + runRequested: true, + }); + + const cloud = parsePowerUserArgs(['workflow', '--spec-file', './SPEC.md', '--mode', 'cloud']); + expect(cloud).toMatchObject({ + command: 'run', + surface: 'workflow', + mode: 'cloud', + specFile: './SPEC.md', + }); + expect(cloud).not.toHaveProperty('runRequested'); + }); + + it('reports invalid workflow mode values instead of defaulting to local', () => { + expect(parsePowerUserArgs(['workflow', '--spec-file', './SPEC.md', '--mode', 'clodu'])).toMatchObject({ + command: 'run', + surface: 'workflow', + specFile: './SPEC.md', + errors: ['--mode must be one of: local, cloud, or both.'], + }); + expect(parsePowerUserArgs(['workflow', '--spec-file', './SPEC.md', '--mode'])).toMatchObject({ + errors: ['--mode must be one of: local, cloud, or both.'], + }); + }); + it('honors explicit auto-fix and refinement disables', () => { const parsed = parsePowerUserArgs(['local', '--spec', 'build a workflow', '--run', '--no-auto-fix', '--no-refine']); diff --git a/src/surfaces/cli/flows/power-user-parser.ts b/src/surfaces/cli/flows/power-user-parser.ts index 5948f2c5..09e2c741 100644 --- a/src/surfaces/cli/flows/power-user-parser.ts +++ b/src/surfaces/cli/flows/power-user-parser.ts @@ -3,7 +3,7 @@ import { isRickyMode } from '../cli/mode-selector.js'; import { DEFAULT_AUTO_FIX_ATTEMPTS } from '../../../shared/constants.js'; export type PowerUserCommand = 'run' | 'help' | 'version' | 'status' | 'connect'; -export type PowerUserSurface = 'legacy' | 'local' | 'cloud' | 'status' | 'connect'; +export type PowerUserSurface = 'legacy' | 'local' | 'cloud' | 'workflow' | 'status' | 'connect'; export type ConnectTarget = 'cloud' | 'agents' | 'integrations'; const DEFAULT_CLOUD_AGENT_TARGETS = ['claude', 'codex', 'opencode', 'gemini']; @@ -66,11 +66,14 @@ export function parsePowerUserArgs(argv: string[]): PowerUserParsedArgs { return parseConnect(argv.slice(1)); } - const surface = first === 'local' || first === 'cloud' ? first : 'legacy'; + const surface = first === 'local' || first === 'cloud' || first === 'workflow' ? first : 'legacy'; const effectiveArgv = surface === 'legacy' ? argv : argv.slice(1); const explicitMode = readMode(effectiveArgv); + const modeFlagPresent = effectiveArgv.includes('--mode'); const mode = surface === 'local' || surface === 'cloud' ? surface + : surface === 'workflow' + ? explicitMode ?? (modeFlagPresent ? undefined : 'local') : explicitMode; const parsed = withCommonFlags( @@ -104,6 +107,9 @@ export function parsePowerUserArgs(argv: string[]): PowerUserParsedArgs { const workforcePersonaWriterCli = parseWorkforcePersonaWriterCliFlag(effectiveArgv); const errors: string[] = [...(parsed.errors ?? [])]; + if (surface === 'workflow' && modeFlagPresent && !explicitMode) { + errors.push('--mode must be one of: local, cloud, or both.'); + } if (effectiveArgv.includes('--workforce-persona') && effectiveArgv.includes('--no-workforce-persona')) { errors.push('--workforce-persona and --no-workforce-persona cannot be combined.'); }