@farmslot/skills 0.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.
@@ -0,0 +1,1097 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawn, spawnSync } = require('node:child_process');
8
+ const { expandTemplate } = require('./lib.cjs');
9
+
10
+ const DEFAULT_SCENARIO_CONFIG = path.resolve(__dirname, '../templates/recipe-cook/scenarios.json');
11
+ const TEMPLATE_PATH = path.resolve(__dirname, '../skills/recipe-cook/references/TASK.md');
12
+ const SKILL_PATH = path.resolve(__dirname, '../skills/recipe-cook/SKILL.md');
13
+ const OVERLAYS = {
14
+ 'example-browser': path.resolve(__dirname, '../skills/recipe-cook/repos/example-browser.md'),
15
+ 'example-mobile': path.resolve(__dirname, '../skills/recipe-cook/repos/example-mobile.md'),
16
+ };
17
+
18
+ function assert(condition, message) {
19
+ if (!condition) {
20
+ throw new Error(message);
21
+ }
22
+ }
23
+
24
+ function parseArgs(argv = process.argv.slice(2)) {
25
+ const result = {
26
+ scenarioId: '',
27
+ scenarioConfig: DEFAULT_SCENARIO_CONFIG,
28
+ outputDir: '',
29
+ runId: '',
30
+ runner: 'claude',
31
+ runnerCmd: '',
32
+ runnerMode: 'stream',
33
+ runnerLog: '',
34
+ model: '',
35
+ slotId: '',
36
+ repoRoot: '',
37
+ cdpPort: '',
38
+ };
39
+
40
+ for (let i = 0; i < argv.length; i += 1) {
41
+ switch (argv[i]) {
42
+ case '--scenario':
43
+ result.scenarioId = argv[++i] || '';
44
+ break;
45
+ case '--scenario-config':
46
+ result.scenarioConfig = path.resolve(argv[++i] || '');
47
+ break;
48
+ case '--output-dir':
49
+ result.outputDir = path.resolve(argv[++i] || '');
50
+ break;
51
+ case '--run-id':
52
+ result.runId = argv[++i] || '';
53
+ break;
54
+ case '--runner':
55
+ result.runner = argv[++i] || '';
56
+ break;
57
+ case '--runner-cmd':
58
+ result.runnerCmd = argv[++i] || '';
59
+ break;
60
+ case '--runner-mode':
61
+ result.runnerMode = argv[++i] || '';
62
+ break;
63
+ case '--runner-log':
64
+ result.runnerLog = path.resolve(argv[++i] || '');
65
+ break;
66
+ case '--model':
67
+ result.model = argv[++i] || '';
68
+ break;
69
+ case '--slot':
70
+ result.slotId = argv[++i] || '';
71
+ break;
72
+ case '--repo-root':
73
+ result.repoRoot = path.resolve(argv[++i] || '');
74
+ break;
75
+ case '--cdp-port':
76
+ result.cdpPort = argv[++i] || '';
77
+ break;
78
+ default:
79
+ break;
80
+ }
81
+ }
82
+
83
+ return result;
84
+ }
85
+
86
+ function readJson(filePath) {
87
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
88
+ }
89
+
90
+ function writeFile(filePath, contents) {
91
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
92
+ fs.writeFileSync(filePath, contents, 'utf8');
93
+ }
94
+
95
+ function writeJson(filePath, value) {
96
+ writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
97
+ }
98
+
99
+ function slugify(value) {
100
+ return (
101
+ String(value || 'run')
102
+ .trim()
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]+/g, '-')
105
+ .replace(/^-|-$/g, '') || 'run'
106
+ );
107
+ }
108
+
109
+ function stripCodeFence(text) {
110
+ const trimmed = String(text || '').trim();
111
+ if (!trimmed.startsWith('```')) return trimmed;
112
+ const lines = trimmed.split('\n');
113
+ lines.shift();
114
+ if (lines[lines.length - 1] && lines[lines.length - 1].trim() === '```') {
115
+ lines.pop();
116
+ }
117
+ return lines.join('\n').trim();
118
+ }
119
+
120
+ function parseJsonEnvelope(rawText) {
121
+ const stripped = stripCodeFence(rawText);
122
+ try {
123
+ return JSON.parse(stripped);
124
+ } catch {
125
+ const firstBrace = stripped.indexOf('{');
126
+ const lastBrace = stripped.lastIndexOf('}');
127
+ assert(
128
+ firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace,
129
+ 'Runner output did not contain parseable JSON.',
130
+ );
131
+ return JSON.parse(stripped.slice(firstBrace, lastBrace + 1));
132
+ }
133
+ }
134
+
135
+ function inferProjectRoot(taskArtifactDir) {
136
+ const normalized = path.normalize(taskArtifactDir);
137
+ const marker = `${path.sep}tasks${path.sep}`;
138
+ const index = normalized.indexOf(marker);
139
+ if (index === -1) {
140
+ return path.dirname(normalized);
141
+ }
142
+ return normalized.slice(0, index);
143
+ }
144
+
145
+ function inferRepoName(scenario, projectRoot) {
146
+ if (scenario.repo) return scenario.repo;
147
+ const basename = path.basename(projectRoot);
148
+ if (basename.includes('extension')) return 'example-browser';
149
+ if (basename.includes('mobile')) return 'example-mobile';
150
+ return basename;
151
+ }
152
+
153
+ function expandHome(value) {
154
+ if (!value) return value;
155
+ if (value === '~') return os.homedir();
156
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
157
+ return value;
158
+ }
159
+
160
+ function getSlotCdpPort(slot) {
161
+ return slot && slot.resources && slot.resources.browser && slot.resources.browser.cdp_port
162
+ ? String(slot.resources.browser.cdp_port)
163
+ : '';
164
+ }
165
+
166
+ function loadPoolSlots(baseDir) {
167
+ const poolDir = path.join(baseDir, 'pool');
168
+ if (!fs.existsSync(poolDir)) return [];
169
+ const slots = [];
170
+ fs.readdirSync(poolDir)
171
+ .filter((name) => name.endsWith('.json'))
172
+ .sort()
173
+ .forEach((name) => {
174
+ const fullPath = path.join(poolDir, name);
175
+ const parsed = readJson(fullPath);
176
+ const poolSlots = Array.isArray(parsed.slots) ? parsed.slots : [];
177
+ poolSlots.forEach((slot) => {
178
+ slots.push({
179
+ ...slot,
180
+ pool_file: fullPath,
181
+ machine: parsed.machine || null,
182
+ project: slot.project || parsed.project || null,
183
+ repo: slot.repo ? path.resolve(expandHome(slot.repo)) : '',
184
+ enabled: slot.enabled !== false,
185
+ cdp_port: getSlotCdpPort(slot),
186
+ });
187
+ });
188
+ });
189
+ return slots;
190
+ }
191
+
192
+ function inferProjectName(projectRoot) {
193
+ return path.basename(projectRoot);
194
+ }
195
+
196
+ function findUp(startDir, predicate) {
197
+ let current = path.resolve(startDir);
198
+ while (true) {
199
+ if (predicate(current)) return current;
200
+ const parent = path.dirname(current);
201
+ if (parent === current) return '';
202
+ current = parent;
203
+ }
204
+ }
205
+
206
+ function inferRepoLocalCdpPort(repoRoot) {
207
+ if (process.env.CDP_PORT) return String(process.env.CDP_PORT);
208
+ const toolkitCandidates = [
209
+ path.join(repoRoot, 'temp', '.agent', 'agentic-toolkit.md'),
210
+ path.join(repoRoot, '.agent', 'agentic-toolkit.md'),
211
+ ];
212
+ for (const filePath of toolkitCandidates) {
213
+ if (!fs.existsSync(filePath)) continue;
214
+ const text = fs.readFileSync(filePath, 'utf8');
215
+ const match = text.match(/CDP(?: on| port)?\s+`?(\d+)`?/i);
216
+ if (match) return match[1];
217
+ }
218
+ return '';
219
+ }
220
+
221
+ function resolveRepoLocalContext({ startDir, repoName }) {
222
+ const repoRoot = findUp(startDir, (candidate) => {
223
+ if (!fs.existsSync(path.join(candidate, '.git'))) return false;
224
+ if (repoName === 'example-browser') {
225
+ return (
226
+ findFilesByBasename(candidate, ['validate-recipe.js', 'validate-flow-schema.js']).length > 0
227
+ );
228
+ }
229
+ if (repoName === 'example-mobile') {
230
+ return findFilesByBasename(candidate, ['validate-recipe.sh']).length > 0;
231
+ }
232
+ return false;
233
+ });
234
+ if (!repoRoot) return null;
235
+ return {
236
+ repoRoot,
237
+ cdpPort: inferRepoLocalCdpPort(repoRoot),
238
+ resolution: 'repo-local',
239
+ };
240
+ }
241
+
242
+ function probeCdpPort(cdpPort) {
243
+ if (!cdpPort) return false;
244
+ const res = spawnSync(
245
+ 'curl',
246
+ ['-s', '--max-time', '2', `http://127.0.0.1:${cdpPort}/json/version`],
247
+ {
248
+ encoding: 'utf8',
249
+ },
250
+ );
251
+ return res.status === 0 && /"Browser"\s*:/.test(res.stdout || '');
252
+ }
253
+
254
+ function resolveSlotContext({ baseDir, slotId, projectName, repoName, repoRoot, cdpPort }) {
255
+ const slots = loadPoolSlots(baseDir);
256
+ if (slots.length === 0) {
257
+ return {
258
+ repoRoot,
259
+ cdpPort,
260
+ slotId: slotId || null,
261
+ resolution: 'manual',
262
+ };
263
+ }
264
+
265
+ const normalizedRepoRoot = repoRoot ? path.resolve(repoRoot) : '';
266
+ let candidates = slots.filter((slot) => slot.enabled);
267
+
268
+ if (slotId) {
269
+ candidates = candidates.filter((slot) => slot.id === slotId);
270
+ } else if (normalizedRepoRoot) {
271
+ candidates = candidates.filter(
272
+ (slot) => slot.repo && path.resolve(slot.repo) === normalizedRepoRoot,
273
+ );
274
+ } else {
275
+ candidates = candidates.filter((slot) => {
276
+ if (projectName && slot.project === projectName) return true;
277
+ if (repoName === 'example-browser' && /example-browser/.test(slot.repo)) return true;
278
+ if (repoName === 'example-mobile' && /example-mobile/.test(slot.repo)) return true;
279
+ return false;
280
+ });
281
+ }
282
+
283
+ if (candidates.length === 0) {
284
+ return {
285
+ repoRoot,
286
+ cdpPort,
287
+ slotId: slotId || null,
288
+ resolution: 'manual',
289
+ };
290
+ }
291
+
292
+ const ranked = candidates
293
+ .map((slot) => ({
294
+ slot,
295
+ live_cdp: probeCdpPort(slot.cdp_port),
296
+ }))
297
+ .sort((left, right) => {
298
+ if (left.live_cdp !== right.live_cdp) return left.live_cdp ? -1 : 1;
299
+ if (Boolean(left.slot.cdp_port) !== Boolean(right.slot.cdp_port))
300
+ return left.slot.cdp_port ? -1 : 1;
301
+ return left.slot.id.localeCompare(right.slot.id);
302
+ });
303
+
304
+ const selected = ranked[0].slot;
305
+ return {
306
+ repoRoot: normalizedRepoRoot || selected.repo || repoRoot,
307
+ cdpPort: cdpPort || selected.cdp_port || '',
308
+ slotId: selected.id,
309
+ resolution: selected.id === slotId ? 'explicit-slot' : 'auto-slot',
310
+ };
311
+ }
312
+
313
+ function findFilesByBasename(rootDir, basenames) {
314
+ if (!rootDir || !fs.existsSync(rootDir)) return [];
315
+ const wanted = new Set(basenames);
316
+ const results = [];
317
+ const queue = [rootDir];
318
+ while (queue.length > 0) {
319
+ const current = queue.shift();
320
+ const entries = fs.readdirSync(current, { withFileTypes: true });
321
+ entries.forEach((entry) => {
322
+ const fullPath = path.join(current, entry.name);
323
+ if (entry.isDirectory()) {
324
+ queue.push(fullPath);
325
+ return;
326
+ }
327
+ if (wanted.has(entry.name)) {
328
+ results.push(fullPath);
329
+ }
330
+ });
331
+ }
332
+ return results.sort();
333
+ }
334
+
335
+ function collectSourceFiles(artifactDir) {
336
+ const preferred = [
337
+ 'review.md',
338
+ 'comment.md',
339
+ 'comments-report.md',
340
+ 'comments-triage.json',
341
+ 'recipe-coverage.md',
342
+ 'learnings.md',
343
+ 'line-comments.json',
344
+ 'grade.json',
345
+ 'meta.json',
346
+ 'probe-state.json',
347
+ 'recipe.json',
348
+ 'baseline-recipe.json',
349
+ 'final-recipe.json',
350
+ ];
351
+
352
+ const seen = new Set();
353
+ const files = [];
354
+ preferred.forEach((name) => {
355
+ const fullPath = path.join(artifactDir, name);
356
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
357
+ seen.add(fullPath);
358
+ files.push(fullPath);
359
+ }
360
+ });
361
+
362
+ fs.readdirSync(artifactDir, { withFileTypes: true }).forEach((entry) => {
363
+ if (!entry.isFile()) return;
364
+ const fullPath = path.join(artifactDir, entry.name);
365
+ if (seen.has(fullPath)) return;
366
+ if (!/\.(md|json|txt)$/i.test(entry.name)) return;
367
+ if (/^(recipe-cook-learning|session-metrics)\.json$/i.test(entry.name)) return;
368
+ if (/(summary|trace|workflow)\.json$/i.test(entry.name)) return;
369
+ files.push(fullPath);
370
+ });
371
+
372
+ return files;
373
+ }
374
+
375
+ function renderSourceBundle(sourceFiles) {
376
+ const MAX_PER_FILE = 15000;
377
+ const MAX_TOTAL = 50000;
378
+ let total = 0;
379
+ const blocks = [];
380
+
381
+ sourceFiles.forEach((filePath) => {
382
+ if (total >= MAX_TOTAL) return;
383
+ const relative = filePath;
384
+ const raw = fs.readFileSync(filePath, 'utf8');
385
+ const truncated =
386
+ raw.length > MAX_PER_FILE ? `${raw.slice(0, MAX_PER_FILE)}\n...[truncated]` : raw;
387
+ const remaining = MAX_TOTAL - total;
388
+ const value =
389
+ truncated.length > remaining ? `${truncated.slice(0, remaining)}\n...[truncated]` : truncated;
390
+ total += value.length;
391
+ blocks.push(`## Source: ${relative}\n\n\`\`\`\n${value}\n\`\`\``);
392
+ });
393
+
394
+ return blocks.join('\n\n');
395
+ }
396
+
397
+ function renderTaskTemplate(templateText, vars) {
398
+ return expandTemplate(templateText, vars).replace(/\r\n/g, '\n');
399
+ }
400
+
401
+ function buildPrompt({
402
+ scenario,
403
+ repoName,
404
+ taskArtifactDir,
405
+ runDir,
406
+ artifactsDir,
407
+ taskPath,
408
+ sourceBundlePath,
409
+ skillPath,
410
+ overlayPath,
411
+ }) {
412
+ return [
413
+ 'You are running the recipe-cook cooking skill as an isolated external runner.',
414
+ 'Read the run-local task file and follow it top-to-bottom.',
415
+ 'This is a batch runner invocation.',
416
+ 'Your job is not finished when you draft a recipe in chat.',
417
+ 'You must prepare the final task and artifact contents first, then emit exactly one JSON object and nothing else.',
418
+ 'Do not wrap the JSON in markdown fences.',
419
+ '',
420
+ '## Scenario',
421
+ `- scenario_id: ${scenario.id}`,
422
+ `- lane: ${scenario.lane}`,
423
+ `- target_repo: ${repoName}`,
424
+ `- source_artifact_dir: ${taskArtifactDir}`,
425
+ `- run_dir: ${runDir}`,
426
+ `- output_artifacts_dir: ${artifactsDir}`,
427
+ `- task_file: ${taskPath}`,
428
+ `- source_bundle_file: ${sourceBundlePath}`,
429
+ `- skill_file: ${skillPath}`,
430
+ `- repo_overlay_file: ${overlayPath}`,
431
+ '',
432
+ '## Required output JSON schema',
433
+ '{',
434
+ ' "task_markdown": "full TASK.md content based on the provided template. Set STATUS to the correct terminal value: done, blocked, or failed. Inside ## Validation Evidence include the exact standalone token RECIPE_COOK_VALIDATION_PENDING.",',
435
+ ' "recipe_json": { "valid": "json object" },',
436
+ ' "recipe_cook_json": { "valid": "json object" } or null,',
437
+ ' "evidence_verdict": "good|ok|bad",',
438
+ ' "next_delta": "one short paragraph describing the next prompt/harness improvement",',
439
+ ' "summary": "one short paragraph summarizing the run"',
440
+ '}',
441
+ '',
442
+ '## Execution contract',
443
+ `Read ${taskPath} first.`,
444
+ `Read ${sourceBundlePath} for the source material.`,
445
+ `Read ${skillPath} for the recipe-cook workflow contract.`,
446
+ `Read ${overlayPath} for repo-specific guidance.`,
447
+ '',
448
+ 'Before you emit the final JSON object, you must treat the following as already-written target files and prepare their final contents:',
449
+ `- ${path.join(runDir, 'TASK.md')}`,
450
+ `- ${path.join(artifactsDir, 'recipe.json')}`,
451
+ `- ${path.join(artifactsDir, 'recipe-cook.json')} when supported`,
452
+ `- ${path.join(artifactsDir, 'recipe-cook-learning.json')}`,
453
+ '',
454
+ 'The JSON object you return is the final serialized payload for those files.',
455
+ 'Emit the JSON last, after you have fully reasoned through:',
456
+ '- the rewritten TASK.md contents, including the correct terminal STATUS',
457
+ '- the recipe artifact contents',
458
+ '- the learning artifact contents',
459
+ '- what validation commands should run',
460
+ '- which validations are truly unavailable vs merely inconvenient',
461
+ '- every checklist item transition from [ ] to [x]',
462
+ '',
463
+ '## Hard requirements',
464
+ '- Follow the recipe-cook workflow order from the task file, skill file, and repo overlay.',
465
+ '- Use the run-local artifact directory paths, not package-root paths, in the task block.',
466
+ '- Do not claim missing proof is resolved; keep unresolved targets explicit.',
467
+ '- Do not edit package files.',
468
+ '- Do not omit the RECIPE_COOK_VALIDATION_PENDING token.',
469
+ '- Do not treat "return one JSON object" as permission to skip the rewritten TASK.md or artifact contents.',
470
+ '- The final JSON object is a transport envelope, not the definition of completion.',
471
+ '- Mark each completed checklist step [x] immediately instead of batching progress updates.',
472
+ '- Use documented runner field names in recipe nodes. For wait_for, prefer timeout_ms and poll_ms instead of guessed aliases like timeout.',
473
+ ].join('\n');
474
+ }
475
+
476
+ function resolveRunnerInvocation({ runner, runnerCmd, model }) {
477
+ if (runnerCmd) {
478
+ return {
479
+ command: runnerCmd,
480
+ args: [],
481
+ shell: true,
482
+ };
483
+ }
484
+
485
+ if (runner === 'claude') {
486
+ const args = ['-p'];
487
+ args.push('--dangerously-skip-permissions');
488
+ if (model) {
489
+ args.push('--model', model);
490
+ }
491
+ args.push('--output-format', 'text');
492
+ return {
493
+ command: 'claude',
494
+ args,
495
+ shell: false,
496
+ };
497
+ }
498
+
499
+ throw new Error(`Unsupported runner: ${runner}`);
500
+ }
501
+
502
+ function runRunnerBatch({ prompt, runner, runnerCmd, model }) {
503
+ const invocation = resolveRunnerInvocation({ runner, runnerCmd, model });
504
+ if (invocation.shell) {
505
+ return spawnSync(invocation.command, {
506
+ shell: true,
507
+ input: prompt,
508
+ encoding: 'utf8',
509
+ maxBuffer: 20 * 1024 * 1024,
510
+ });
511
+ }
512
+
513
+ return spawnSync(invocation.command, invocation.args, {
514
+ input: prompt,
515
+ encoding: 'utf8',
516
+ maxBuffer: 20 * 1024 * 1024,
517
+ });
518
+ }
519
+
520
+ function runRunnerStream({ prompt, runner, runnerCmd, model, runnerLogPath, promptPath }) {
521
+ const invocation = resolveRunnerInvocation({ runner, runnerCmd, model });
522
+ const logPath = runnerLogPath || path.join(os.tmpdir(), `recipe-cook-runner-${Date.now()}.log`);
523
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
524
+ const logStream = fs.createWriteStream(logPath, { encoding: 'utf8' });
525
+
526
+ return new Promise((resolve, reject) => {
527
+ const startedAt = Date.now();
528
+ let sawAnyOutput = false;
529
+ let heartbeat = null;
530
+ const commandPreview = invocation.shell
531
+ ? invocation.command
532
+ : [invocation.command].concat(invocation.args || []).join(' ');
533
+ const emitStatus = (message) => {
534
+ const line = `[recipe-cook] ${new Date().toISOString()} ${message}\n`;
535
+ logStream.write(line);
536
+ process.stderr.write(line);
537
+ };
538
+ const child = invocation.shell
539
+ ? spawn(invocation.command, {
540
+ shell: true,
541
+ stdio: 'pipe',
542
+ })
543
+ : spawn(invocation.command, invocation.args, {
544
+ stdio: 'pipe',
545
+ });
546
+
547
+ let stdout = '';
548
+ let stderr = '';
549
+ let settled = false;
550
+
551
+ const finish = (result) => {
552
+ if (settled) return;
553
+ settled = true;
554
+ if (heartbeat) clearInterval(heartbeat);
555
+ logStream.end(() => resolve(result));
556
+ };
557
+
558
+ const fail = (error) => {
559
+ if (settled) return;
560
+ settled = true;
561
+ if (heartbeat) clearInterval(heartbeat);
562
+ logStream.end(() => reject(error));
563
+ };
564
+
565
+ emitStatus(`runner started in stream mode`);
566
+ emitStatus(`command: ${commandPreview}`);
567
+ if (promptPath) {
568
+ emitStatus(`stdin: ${promptPath}`);
569
+ emitStatus(`debug repro: cat ${JSON.stringify(promptPath)} | ${commandPreview}`);
570
+ }
571
+ emitStatus('waiting for runner output...');
572
+ heartbeat = setInterval(() => {
573
+ if (settled || sawAnyOutput) return;
574
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
575
+ emitStatus(`still waiting for first runner output... ${elapsed}s elapsed`);
576
+ }, 15000);
577
+
578
+ child.on('error', (error) => {
579
+ fail(error);
580
+ });
581
+
582
+ child.stdout.on('data', (chunk) => {
583
+ const text = chunk.toString('utf8');
584
+ if (!sawAnyOutput) {
585
+ sawAnyOutput = true;
586
+ emitStatus('received first runner output chunk');
587
+ }
588
+ stdout += text;
589
+ logStream.write(text);
590
+ process.stderr.write(text);
591
+ });
592
+
593
+ child.stderr.on('data', (chunk) => {
594
+ const text = chunk.toString('utf8');
595
+ if (!sawAnyOutput) {
596
+ sawAnyOutput = true;
597
+ emitStatus('received first runner output chunk');
598
+ }
599
+ stderr += text;
600
+ logStream.write(text);
601
+ process.stderr.write(text);
602
+ });
603
+
604
+ child.on('close', (code, signal) => {
605
+ finish({
606
+ status: typeof code === 'number' ? code : -1,
607
+ signal: signal || null,
608
+ stdout,
609
+ stderr,
610
+ logPath,
611
+ });
612
+ });
613
+
614
+ child.stdin.on('error', () => {});
615
+ child.stdin.end(prompt);
616
+ });
617
+ }
618
+
619
+ async function runRunner({
620
+ prompt,
621
+ runner,
622
+ runnerCmd,
623
+ runnerMode,
624
+ model,
625
+ runnerLogPath,
626
+ promptPath,
627
+ }) {
628
+ if (!runnerMode || runnerMode === 'batch') {
629
+ const result = runRunnerBatch({
630
+ prompt,
631
+ runner,
632
+ runnerCmd,
633
+ model,
634
+ });
635
+ return {
636
+ status: result.status,
637
+ signal: result.signal || null,
638
+ stdout: result.stdout || '',
639
+ stderr: result.stderr || '',
640
+ logPath: runnerLogPath || null,
641
+ };
642
+ }
643
+
644
+ if (runnerMode === 'stream') {
645
+ return runRunnerStream({
646
+ prompt,
647
+ runner,
648
+ runnerCmd,
649
+ model,
650
+ runnerLogPath,
651
+ promptPath,
652
+ });
653
+ }
654
+
655
+ throw new Error(`Unsupported runner mode: ${runnerMode}`);
656
+ }
657
+
658
+ function discoverValidationSteps({ repoName, repoRoot, recipePath, artifactsDir, cdpPort }) {
659
+ if (!repoRoot || !fs.existsSync(repoRoot)) {
660
+ return [];
661
+ }
662
+
663
+ if (repoName === 'example-browser') {
664
+ const schemaValidator = findFilesByBasename(repoRoot, ['validate-flow-schema.js'])[0];
665
+ const recipeValidator = findFilesByBasename(repoRoot, ['validate-recipe.js'])[0];
666
+ if (!schemaValidator || !recipeValidator) {
667
+ return [];
668
+ }
669
+ const steps = [
670
+ {
671
+ name: 'schema_validation',
672
+ command: ['node', schemaValidator, recipePath],
673
+ cwd: repoRoot,
674
+ },
675
+ {
676
+ name: 'dry_run',
677
+ command: ['node', recipeValidator, '--recipe', recipePath, '--dry-run'],
678
+ cwd: repoRoot,
679
+ },
680
+ ];
681
+ if (cdpPort) {
682
+ steps.push({
683
+ name: 'live_run',
684
+ command: [
685
+ 'node',
686
+ recipeValidator,
687
+ '--recipe',
688
+ recipePath,
689
+ '--cdp-port',
690
+ String(cdpPort),
691
+ '--skip-manual',
692
+ '--artifacts-dir',
693
+ artifactsDir,
694
+ ],
695
+ cwd: repoRoot,
696
+ });
697
+ }
698
+ return steps;
699
+ }
700
+
701
+ if (repoName === 'example-mobile') {
702
+ const shellValidator = findFilesByBasename(repoRoot, ['validate-recipe.sh'])[0];
703
+ if (!shellValidator) {
704
+ return [];
705
+ }
706
+ const steps = [
707
+ {
708
+ name: 'dry_run',
709
+ command: ['bash', shellValidator, artifactsDir, '--dry-run'],
710
+ cwd: repoRoot,
711
+ },
712
+ ];
713
+ if (cdpPort) {
714
+ steps.push({
715
+ name: 'live_run',
716
+ command: ['bash', shellValidator, artifactsDir, '--skip-manual'],
717
+ cwd: repoRoot,
718
+ });
719
+ }
720
+ return steps;
721
+ }
722
+
723
+ return [];
724
+ }
725
+
726
+ function runValidationSteps(steps, validationDir) {
727
+ const results = {};
728
+ steps.forEach((step) => {
729
+ const commandString = step.command
730
+ .map((part) => (/\s/.test(part) ? JSON.stringify(part) : part))
731
+ .join(' ');
732
+ const res = spawnSync(step.command[0], step.command.slice(1), {
733
+ cwd: step.cwd,
734
+ encoding: 'utf8',
735
+ maxBuffer: 20 * 1024 * 1024,
736
+ });
737
+ const logPath = path.join(validationDir, `${step.name}.log`);
738
+ writeFile(logPath, [`$ ${commandString}`, '', res.stdout || '', res.stderr || ''].join('\n'));
739
+ results[step.name] = {
740
+ command: commandString,
741
+ exit_code: typeof res.status === 'number' ? res.status : -1,
742
+ log_path: logPath,
743
+ };
744
+ });
745
+ return results;
746
+ }
747
+
748
+ function renderValidationEvidence(validationResults, cdpPort, repoRoot) {
749
+ const lines = [];
750
+ const stepOrder = ['schema_validation', 'dry_run', 'live_run'];
751
+ stepOrder.forEach((name) => {
752
+ const result = validationResults[name];
753
+ if (!result) return;
754
+ lines.push(`- ${name}: \`${result.command}\``);
755
+ lines.push(` - exit: ${result.exit_code}`);
756
+ lines.push(` - log: ${result.log_path}`);
757
+ });
758
+
759
+ if (
760
+ !validationResults.schema_validation &&
761
+ !validationResults.dry_run &&
762
+ !validationResults.live_run
763
+ ) {
764
+ lines.push(
765
+ `- validation unavailable: no repo-local validator/runner discovered under ${repoRoot || 'unspecified repo root'}`,
766
+ );
767
+ } else if (!validationResults.live_run) {
768
+ if (!cdpPort) {
769
+ lines.push(
770
+ '- live validation unavailable: missing `--cdp-port` for a live runner invocation',
771
+ );
772
+ } else {
773
+ lines.push('- live validation unavailable: no live runner was discovered');
774
+ }
775
+ }
776
+
777
+ return lines.join('\n');
778
+ }
779
+
780
+ function deriveTerminalState(taskMarkdown, validationResults) {
781
+ const text = String(taskMarkdown || '');
782
+ if (/\bSTATUS:\s*blocked\b/i.test(text)) {
783
+ return { status: 'blocked', outcome: 'partial' };
784
+ }
785
+ if (/\bSTATUS:\s*failed\b/i.test(text)) {
786
+ return { status: 'failed', outcome: 'failure' };
787
+ }
788
+ if (/^\s*[-*]\s+.*:\s*UNRESOLVED\b/m.test(text)) {
789
+ return { status: 'blocked', outcome: 'partial' };
790
+ }
791
+
792
+ const failingStep = Object.values(validationResults || {}).find(
793
+ (result) => result.exit_code !== 0 && result.exit_code !== -1,
794
+ );
795
+ if (failingStep) {
796
+ return { status: 'failed', outcome: 'failure' };
797
+ }
798
+
799
+ return { status: 'done', outcome: 'success' };
800
+ }
801
+
802
+ function injectValidationEvidence(taskMarkdown, evidenceBlock) {
803
+ let next = String(taskMarkdown || '');
804
+ if (/## Validation Evidence[\s\S]*?(?=\n## |\s*$)/.test(next)) {
805
+ next = next.replace(
806
+ /## Validation Evidence[\s\S]*?(?=\n## |\s*$)/,
807
+ `## Validation Evidence\n\n${evidenceBlock}\n`,
808
+ );
809
+ } else if (next.includes('RECIPE_COOK_VALIDATION_PENDING')) {
810
+ next = next.replace('RECIPE_COOK_VALIDATION_PENDING', evidenceBlock);
811
+ } else {
812
+ next = `${next.trim()}\n\n## Validation Evidence\n\n${evidenceBlock}\n`;
813
+ }
814
+ next = next.replace(/RECIPE_COOK_VALIDATION_PENDING/g, '').replace(/\n{3,}/g, '\n\n');
815
+ return next.endsWith('\n') ? next : `${next}\n`;
816
+ }
817
+
818
+ function normalizeTaskArtifactDir(taskMarkdown, artifactsDir) {
819
+ if (!/ARTIFACT_DIR:/.test(taskMarkdown)) {
820
+ return taskMarkdown;
821
+ }
822
+ return taskMarkdown.replace(/ARTIFACT_DIR:\s*.*/, `ARTIFACT_DIR: ${artifactsDir}`);
823
+ }
824
+
825
+ function mapVerdict(value, validationResults) {
826
+ const candidate = ['good', 'ok', 'bad'].includes(value) ? value : 'ok';
827
+ const failingStep = Object.values(validationResults).find(
828
+ (result) => result.exit_code !== 0 && result.exit_code !== -1,
829
+ );
830
+ if (failingStep) return 'bad';
831
+ return candidate;
832
+ }
833
+
834
+ function nowIso() {
835
+ return new Date().toISOString();
836
+ }
837
+
838
+ async function main() {
839
+ const args = parseArgs();
840
+ assert(args.scenarioId, 'Missing required --scenario <id>');
841
+ assert(
842
+ ['batch', 'stream'].includes(args.runnerMode),
843
+ `Unsupported --runner-mode: ${args.runnerMode}`,
844
+ );
845
+
846
+ const scenarioConfig = readJson(args.scenarioConfig);
847
+ assert(
848
+ Array.isArray(scenarioConfig.scenarios),
849
+ `Invalid scenario config: ${args.scenarioConfig}`,
850
+ );
851
+ const scenario = scenarioConfig.scenarios.find((entry) => entry.id === args.scenarioId);
852
+ assert(scenario, `Scenario not found: ${args.scenarioId}`);
853
+
854
+ const taskArtifactDir = path.resolve(process.cwd(), scenario.task_artifact_dir);
855
+ assert(
856
+ fs.existsSync(taskArtifactDir),
857
+ `Scenario artifact dir does not exist: ${taskArtifactDir}`,
858
+ );
859
+
860
+ const inferredProjectRoot = path.resolve(
861
+ process.cwd(),
862
+ inferProjectRoot(scenario.task_artifact_dir),
863
+ );
864
+ const projectName = inferProjectName(inferredProjectRoot);
865
+ const repoName = inferRepoName(scenario, inferredProjectRoot);
866
+ const repoLocalContext =
867
+ !args.repoRoot && !args.slotId
868
+ ? resolveRepoLocalContext({ startDir: process.cwd(), repoName })
869
+ : null;
870
+ const slotContext = resolveSlotContext({
871
+ baseDir: process.cwd(),
872
+ slotId: args.slotId,
873
+ projectName,
874
+ repoName,
875
+ repoRoot: args.repoRoot || (repoLocalContext ? repoLocalContext.repoRoot : ''),
876
+ cdpPort: args.cdpPort || (repoLocalContext ? repoLocalContext.cdpPort : ''),
877
+ });
878
+ const projectRoot =
879
+ slotContext.repoRoot ||
880
+ (repoLocalContext ? repoLocalContext.repoRoot : '') ||
881
+ args.repoRoot ||
882
+ inferredProjectRoot;
883
+ const effectiveCdpPort =
884
+ slotContext.cdpPort || (repoLocalContext ? repoLocalContext.cdpPort : '') || args.cdpPort;
885
+ const overlayPath = OVERLAYS[repoName];
886
+ assert(
887
+ overlayPath && fs.existsSync(overlayPath),
888
+ `Missing recipe-cook overlay for repo: ${repoName}`,
889
+ );
890
+
891
+ const runId =
892
+ args.runId || `${slugify(scenario.id)}-${new Date().toISOString().replace(/[:.]/g, '-')}`;
893
+ const runDir =
894
+ args.outputDir ||
895
+ path.resolve(process.cwd(), '.omx', 'artifacts', 'recipe-cook-runs', scenario.id, runId);
896
+ const artifactsDir = path.join(runDir, 'artifacts');
897
+ const validationDir = path.join(artifactsDir, 'validation');
898
+ const runnerLogPath =
899
+ args.runnerLog || (args.runnerMode === 'stream' ? path.join(runDir, 'runner.log') : '');
900
+ fs.mkdirSync(artifactsDir, { recursive: true });
901
+ fs.mkdirSync(validationDir, { recursive: true });
902
+
903
+ assert(fs.existsSync(SKILL_PATH), `Missing recipe-cook skill: ${SKILL_PATH}`);
904
+ const templateText = fs.readFileSync(TEMPLATE_PATH, 'utf8');
905
+ const sourceFiles = collectSourceFiles(taskArtifactDir);
906
+ const sourceBundle = renderSourceBundle(sourceFiles);
907
+ const sourceBundlePath = path.join(runDir, 'SOURCE-BUNDLE.md');
908
+ writeFile(sourceBundlePath, sourceBundle || 'No source files found.\n');
909
+
910
+ const taskPath = path.join(runDir, 'TASK.md');
911
+ const taskTemplateVars = {
912
+ SCENARIO_ID: scenario.id,
913
+ TARGET_REPO: repoName,
914
+ REPO_ROOT: projectRoot,
915
+ SOURCE_KIND: scenario.lane,
916
+ SOURCE_REF: scenario.id,
917
+ SOURCE_ARTIFACT_DIR: taskArtifactDir,
918
+ TASK_DIR: runDir,
919
+ ARTIFACT_DIR: artifactsDir,
920
+ SOURCE_BUNDLE_FILE: sourceBundlePath,
921
+ WRAPPER: 'recipe-cook',
922
+ VALIDATION_MODE: effectiveCdpPort ? 'live' : 'batch-isolated',
923
+ };
924
+ const taskTemplate = renderTaskTemplate(templateText, taskTemplateVars);
925
+ writeFile(taskPath, taskTemplate);
926
+
927
+ const prompt = buildPrompt({
928
+ scenario,
929
+ repoName,
930
+ taskArtifactDir,
931
+ runDir,
932
+ artifactsDir,
933
+ taskPath,
934
+ sourceBundlePath,
935
+ skillPath: SKILL_PATH,
936
+ overlayPath,
937
+ });
938
+
939
+ const promptPath = path.join(runDir, 'prompt.txt');
940
+ writeFile(promptPath, prompt);
941
+ writeJson(path.join(runDir, 'run-meta.json'), {
942
+ scenario_id: scenario.id,
943
+ lane: scenario.lane,
944
+ repo: repoName,
945
+ project_name: projectName,
946
+ project_root: projectRoot,
947
+ slot_id: slotContext.slotId,
948
+ slot_resolution: repoLocalContext ? repoLocalContext.resolution : slotContext.resolution,
949
+ cdp_port: effectiveCdpPort || null,
950
+ source_artifact_dir: taskArtifactDir,
951
+ run_dir: runDir,
952
+ artifacts_dir: artifactsDir,
953
+ prompt_path: promptPath,
954
+ runner: args.runner,
955
+ runner_cmd: args.runnerCmd || null,
956
+ runner_mode: args.runnerMode,
957
+ runner_log: runnerLogPath || null,
958
+ model: args.model || null,
959
+ started_at: nowIso(),
960
+ });
961
+
962
+ const runnerResult = await runRunner({
963
+ prompt,
964
+ runner: args.runner,
965
+ runnerCmd: args.runnerCmd,
966
+ runnerMode: args.runnerMode,
967
+ model: args.model,
968
+ runnerLogPath,
969
+ promptPath,
970
+ });
971
+ const rawOutput = [runnerResult.stdout || '', runnerResult.stderr || ''].join('\n').trim();
972
+ writeFile(path.join(runDir, 'runner-output.txt'), `${rawOutput}\n`);
973
+ assert(
974
+ runnerResult.status === 0,
975
+ `Runner exited with status ${runnerResult.status}. See ${path.join(runDir, 'runner-output.txt')}`,
976
+ );
977
+
978
+ const envelope = parseJsonEnvelope(rawOutput);
979
+ assert(
980
+ typeof envelope.task_markdown === 'string' && envelope.task_markdown.trim(),
981
+ 'Runner output missing task_markdown',
982
+ );
983
+ assert(
984
+ envelope.recipe_json &&
985
+ typeof envelope.recipe_json === 'object' &&
986
+ !Array.isArray(envelope.recipe_json),
987
+ 'Runner output missing recipe_json object',
988
+ );
989
+ assert(
990
+ envelope.recipe_cook_json == null ||
991
+ (typeof envelope.recipe_cook_json === 'object' && !Array.isArray(envelope.recipe_cook_json)),
992
+ 'recipe_cook_json must be an object or null',
993
+ );
994
+ assert(
995
+ typeof envelope.next_delta === 'string' && envelope.next_delta.trim(),
996
+ 'Runner output missing next_delta',
997
+ );
998
+ writeJson(path.join(runDir, 'runner-response.json'), envelope);
999
+
1000
+ const recipePath = path.join(artifactsDir, 'recipe.json');
1001
+ const recipeCookPath = envelope.recipe_cook_json
1002
+ ? path.join(artifactsDir, 'recipe-cook.json')
1003
+ : null;
1004
+ writeJson(recipePath, envelope.recipe_json);
1005
+ if (recipeCookPath) {
1006
+ writeJson(recipeCookPath, envelope.recipe_cook_json);
1007
+ }
1008
+
1009
+ const validationSteps = discoverValidationSteps({
1010
+ repoName,
1011
+ repoRoot: projectRoot,
1012
+ recipePath,
1013
+ artifactsDir,
1014
+ cdpPort: effectiveCdpPort,
1015
+ });
1016
+ const validationResults = runValidationSteps(validationSteps, validationDir);
1017
+ const evidenceBlock = renderValidationEvidence(validationResults, effectiveCdpPort, projectRoot);
1018
+ const normalizedTask = normalizeTaskArtifactDir(envelope.task_markdown, artifactsDir);
1019
+ let finalTask = injectValidationEvidence(normalizedTask, evidenceBlock);
1020
+ const terminalState = deriveTerminalState(finalTask, validationResults);
1021
+ finalTask = finalTask.replace(
1022
+ /STATUS:\s*(pending|working|done|blocked|failed)/i,
1023
+ `STATUS: ${terminalState.status}`,
1024
+ );
1025
+ writeFile(path.join(runDir, 'TASK.md'), finalTask);
1026
+
1027
+ const verdict = mapVerdict(envelope.evidence_verdict, validationResults);
1028
+ const completedAt = nowIso();
1029
+ writeJson(path.join(artifactsDir, 'meta.json'), {
1030
+ runner: args.runner,
1031
+ model: args.model || 'unknown',
1032
+ tier: 'external',
1033
+ recommendation: 'COMMENT',
1034
+ started_at: readJson(path.join(runDir, 'run-meta.json')).started_at,
1035
+ completed_at: completedAt,
1036
+ validate_exit: validationResults.live_run
1037
+ ? validationResults.live_run.exit_code
1038
+ : validationResults.dry_run
1039
+ ? validationResults.dry_run.exit_code
1040
+ : -1,
1041
+ outcome:
1042
+ terminalState.outcome === 'failure'
1043
+ ? 'failed'
1044
+ : terminalState.outcome === 'partial'
1045
+ ? 'blocked'
1046
+ : 'completed',
1047
+ });
1048
+ writeJson(path.join(artifactsDir, 'grade.json'), {
1049
+ recipe_semantic: verdict,
1050
+ reasoning: envelope.summary || envelope.next_delta,
1051
+ graded_by: args.runner === 'claude' ? 'claude-external-runner' : 'external-runner',
1052
+ graded_at: completedAt,
1053
+ });
1054
+ writeJson(path.join(artifactsDir, 'recipe-cook-learning.json'), {
1055
+ run_id: runId,
1056
+ scenario_type: scenario.lane,
1057
+ target_repo: repoName,
1058
+ branch: null,
1059
+ artifact_dir: artifactsDir,
1060
+ recipe_path: recipePath,
1061
+ recipe_cook_path: recipeCookPath,
1062
+ validation_results: validationResults,
1063
+ evidence_verdict: verdict,
1064
+ next_delta: envelope.next_delta,
1065
+ touched_files: [],
1066
+ source_scenario_id: scenario.id,
1067
+ source_artifact_dir: taskArtifactDir,
1068
+ slot_id: slotContext.slotId || null,
1069
+ runner: args.runner,
1070
+ model: args.model || null,
1071
+ });
1072
+
1073
+ const runMeta = readJson(path.join(runDir, 'run-meta.json'));
1074
+ runMeta.completed_at = completedAt;
1075
+ writeJson(path.join(runDir, 'run-meta.json'), runMeta);
1076
+
1077
+ process.stdout.write(`${runDir}\n`);
1078
+ }
1079
+
1080
+ if (require.main === module) {
1081
+ main().catch((error) => {
1082
+ process.stderr.write(`${error && error.stack ? error.stack : String(error)}\n`);
1083
+ process.exit(1);
1084
+ });
1085
+ }
1086
+
1087
+ module.exports = {
1088
+ buildPrompt,
1089
+ collectSourceFiles,
1090
+ discoverValidationSteps,
1091
+ injectValidationEvidence,
1092
+ normalizeTaskArtifactDir,
1093
+ parseJsonEnvelope,
1094
+ renderSourceBundle,
1095
+ runRunner,
1096
+ stripCodeFence,
1097
+ };