Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/local/entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...',
]);
});
Expand Down
8 changes: 7 additions & 1 deletion src/local/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. ' +
Expand All @@ -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}`));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
52 changes: 49 additions & 3 deletions src/product/generation/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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'],
Expand All @@ -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),
};
Expand All @@ -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' }),
Expand Down
23 changes: 22 additions & 1 deletion src/product/generation/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) ||
Expand Down
5 changes: 3 additions & 2 deletions src/product/generation/template-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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.`)},
Expand Down
3 changes: 2 additions & 1 deletion src/product/generation/workforce-persona-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion src/product/generation/workforce-persona-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
31 changes: 31 additions & 0 deletions src/surfaces/cli/commands/cli-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -335,6 +363,9 @@ describe('renderHelp', () => {

expect(helpText).toContain('Happy path:');
expect(helpText).toContain('ricky local --spec <text>');
expect(helpText).toContain('ricky workflow --spec-file <path> --run');
expect(helpText).toContain('ricky workflow --spec-file <path> --mode cloud');
expect(helpText).not.toContain('ricky workflow --spec-file <path> --mode cloud --run');
expect(helpText).toContain('ricky run <path> --background');
expect(helpText).toContain('ricky run <artifact> --start-from <step>');
expect(helpText).toContain('ricky status --run <run-id>');
Expand Down
4 changes: 4 additions & 0 deletions src/surfaces/cli/commands/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --run Generate, then run a workflow',
' ricky workflow --spec-file <path> --mode cloud Generate with Cloud',
' ricky cloud --spec <text> Generate with Cloud',
' ricky run <path> Run attached in this terminal',
'',
'Usage:',
' ricky local --spec-file <path> --run Generate, then run locally',
' ricky workflow --spec-file <path> --run One command: spec -> workflow -> local run',
' ricky workflow --spec-file <path> --mode cloud One command: spec -> Cloud workflow artifact',
' ricky --mode <mode> Mode preset: local | cloud | both',
' ricky --mode local --spec <text> Generate artifact only',
' ricky --mode local --spec <text> --run Generate, then execute',
Expand Down
31 changes: 31 additions & 0 deletions src/surfaces/cli/flows/power-user-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
10 changes: 8 additions & 2 deletions src/surfaces/cli/flows/power-user-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const parsed = withCommonFlags(
Expand Down Expand Up @@ -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.');
}
Expand Down