@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.
- package/CHANGELOG.md +7 -0
- package/README.md +74 -0
- package/bin/farmslot-skills.mjs +131 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/schemas/recipe-cook-learning.schema.json +31 -0
- package/schemas/recipe-cook-patch-metadata.schema.json +19 -0
- package/schemas/recipe-cook-run-ledger-entry.schema.json +37 -0
- package/schemas/recipe-cook-validator-manifest.schema.json +92 -0
- package/schemas/recipe-cook-version-ledger.schema.json +49 -0
- package/scripts/lib.cjs +356 -0
- package/scripts/prepare-cooking-run.cjs +310 -0
- package/scripts/record-validator-run.cjs +339 -0
- package/scripts/run-cooking-lane.cjs +1097 -0
- package/skills/project-adopt/SKILL.md +33 -0
- package/skills/recipe-cook/SKILL.md +147 -0
- package/skills/recipe-cook/TASK.md +114 -0
- package/skills/recipe-cook/references/TASK.md +176 -0
- package/skills/recipe-cook/references/VALIDATOR-LOOP.md +54 -0
- package/skills/recipe-cook/repos/example-browser.md +50 -0
- package/skills/recipe-cook/repos/example-mobile.md +61 -0
- package/skills/recipe-doctor/SKILL.md +39 -0
- package/skills/recipe-harness/SKILL.md +31 -0
- package/skills/recipe-quality/SKILL.md +45 -0
- package/templates/recipe-cook/scenarios.json +24 -0
package/scripts/lib.cjs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function readJsonIfExists(filePath) {
|
|
8
|
+
if (!filePath || !fs.existsSync(filePath)) return null;
|
|
9
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function assert(condition, message) {
|
|
13
|
+
if (!condition) {
|
|
14
|
+
throw new Error(message);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readJsonLines(filePath) {
|
|
19
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
20
|
+
return fs
|
|
21
|
+
.readFileSync(filePath, 'utf8')
|
|
22
|
+
.split('\n')
|
|
23
|
+
.map((line) => line.trim())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((line) => JSON.parse(line));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeJson(filePath, value) {
|
|
29
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
30
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function appendJsonl(filePath, value) {
|
|
34
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
35
|
+
fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function expandTemplate(template, vars = {}) {
|
|
39
|
+
let result = String(template || '');
|
|
40
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
41
|
+
const rendered = value == null ? '' : String(value);
|
|
42
|
+
result = result.replaceAll(`{{${key}}}`, rendered);
|
|
43
|
+
result = result.replaceAll(`{{${String(key).toUpperCase()}}}`, rendered);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function qualitySemanticToScore(semantic) {
|
|
49
|
+
if (semantic === 'good') return 1.0;
|
|
50
|
+
if (semantic === 'ok') return 0.6;
|
|
51
|
+
return 0.0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolvePassRate(meta, laneResult) {
|
|
55
|
+
if (laneResult.live_run_exit === 0) return 1.0;
|
|
56
|
+
if (typeof laneResult.live_run_exit === 'number' && laneResult.live_run_exit !== 0) return 0.0;
|
|
57
|
+
if (meta && meta.validate_exit === 0) return 1.0;
|
|
58
|
+
if (meta && typeof meta.validate_exit === 'number' && meta.validate_exit > 0) return 0.0;
|
|
59
|
+
if (meta && meta.validate_exit === -1) return null;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveInferenceCost(sessionMetrics) {
|
|
64
|
+
if (sessionMetrics && typeof sessionMetrics.costEstimate === 'number')
|
|
65
|
+
return sessionMetrics.costEstimate;
|
|
66
|
+
const costString = sessionMetrics && sessionMetrics.session && sessionMetrics.session.cost_usd;
|
|
67
|
+
if (costString != null) {
|
|
68
|
+
const parsed = Number(costString);
|
|
69
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeCostEfficiency(cost, baseline) {
|
|
75
|
+
if (cost == null || baseline == null || baseline <= 0) return null;
|
|
76
|
+
return 1 - Math.min(cost / baseline, 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function aggregateAverage(values) {
|
|
80
|
+
const filtered = values.filter((value) => value != null);
|
|
81
|
+
if (filtered.length === 0) return null;
|
|
82
|
+
return filtered.reduce((sum, value) => sum + value, 0) / filtered.length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const RECIPE_COOK_SELF_EDIT_ROOTS = [
|
|
86
|
+
'recipe-cook/',
|
|
87
|
+
'packages/skills/skills/recipe-cook/',
|
|
88
|
+
'.agents/skills/recipe-cook/',
|
|
89
|
+
'.codex/skills/recipe-cook/',
|
|
90
|
+
'.claude/skills/recipe-cook/',
|
|
91
|
+
'.cursor/skills/recipe-cook/',
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function normalizeRepoPath(value) {
|
|
95
|
+
return String(value || '')
|
|
96
|
+
.replace(/\\/g, '/')
|
|
97
|
+
.replace(/^\.\//, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isRecipeCookSelfEditPath(filePath) {
|
|
101
|
+
const normalized = normalizeRepoPath(filePath);
|
|
102
|
+
return RECIPE_COOK_SELF_EDIT_ROOTS.some((root) => normalized.startsWith(root));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateLearningArtifact(artifact, expectedArtifactDir) {
|
|
106
|
+
const required = [
|
|
107
|
+
'run_id',
|
|
108
|
+
'scenario_type',
|
|
109
|
+
'target_repo',
|
|
110
|
+
'artifact_dir',
|
|
111
|
+
'validation_results',
|
|
112
|
+
'evidence_verdict',
|
|
113
|
+
'recipe_path',
|
|
114
|
+
'next_delta',
|
|
115
|
+
'touched_files',
|
|
116
|
+
];
|
|
117
|
+
required.forEach((field) =>
|
|
118
|
+
assert(
|
|
119
|
+
Object.prototype.hasOwnProperty.call(artifact || {}, field),
|
|
120
|
+
`Learning artifact missing required field: ${field}`,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
const normalizePath = (value) => {
|
|
124
|
+
const resolved = path.resolve(value);
|
|
125
|
+
try {
|
|
126
|
+
return fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved);
|
|
127
|
+
} catch {
|
|
128
|
+
return resolved;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
assert(
|
|
132
|
+
normalizePath(artifact.artifact_dir) === normalizePath(expectedArtifactDir),
|
|
133
|
+
'Learning artifact artifact_dir does not match scenario artifact dir',
|
|
134
|
+
);
|
|
135
|
+
assert(Array.isArray(artifact.touched_files), 'Learning artifact touched_files must be an array');
|
|
136
|
+
const illegalTouch = artifact.touched_files.find((item) => isRecipeCookSelfEditPath(item));
|
|
137
|
+
assert(!illegalTouch, `Cooking run illegally touched recipe-cook package path: ${illegalTouch}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compareScoreVectors(current, previous) {
|
|
141
|
+
const currentBalanced = current.balanced_score;
|
|
142
|
+
const previousBalanced =
|
|
143
|
+
previous && previous.balanced_score != null ? previous.balanced_score : null;
|
|
144
|
+
|
|
145
|
+
if (currentBalanced != null && previousBalanced != null) {
|
|
146
|
+
if (currentBalanced > previousBalanced) return 1;
|
|
147
|
+
if (currentBalanced < previousBalanced) return -1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (current.quality > (previous ? previous.quality : -1)) return 1;
|
|
151
|
+
if (current.quality < (previous ? previous.quality : -1)) return -1;
|
|
152
|
+
if (current.pass_rate > (previous ? previous.pass_rate : -1)) return 1;
|
|
153
|
+
if (current.pass_rate < (previous ? previous.pass_rate : -1)) return -1;
|
|
154
|
+
if ((current.cost_efficiency ?? -1) > (previous ? (previous.cost_efficiency ?? -1) : -1))
|
|
155
|
+
return 1;
|
|
156
|
+
if ((current.cost_efficiency ?? -1) < (previous ? (previous.cost_efficiency ?? -1) : -1))
|
|
157
|
+
return -1;
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function decisionFromScores(scoreVector, laneResults, previousVersion) {
|
|
162
|
+
const floorsMet = laneResults.every(
|
|
163
|
+
(lane) => lane.quality >= 0.8 && lane.pass_rate != null && lane.pass_rate >= 0.8,
|
|
164
|
+
);
|
|
165
|
+
if (!floorsMet) {
|
|
166
|
+
return { decision: 'reject', reason: 'One or more lanes failed quality/pass-rate floors.' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (scoreVector.cost_efficiency == null) {
|
|
170
|
+
if (!previousVersion) {
|
|
171
|
+
return {
|
|
172
|
+
decision: 'reject',
|
|
173
|
+
reason: 'Missing cost coverage cannot promote the first candidate to keep.',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
previousVersion.aggregate_score_vector &&
|
|
178
|
+
previousVersion.aggregate_score_vector.cost_efficiency != null
|
|
179
|
+
) {
|
|
180
|
+
const previousComparable = {
|
|
181
|
+
quality: previousVersion.aggregate_score_vector.quality,
|
|
182
|
+
pass_rate: previousVersion.aggregate_score_vector.pass_rate,
|
|
183
|
+
cost_efficiency: previousVersion.aggregate_score_vector.cost_efficiency,
|
|
184
|
+
balanced_score: null,
|
|
185
|
+
};
|
|
186
|
+
const compare = compareScoreVectors(scoreVector, previousComparable);
|
|
187
|
+
if (compare >= 0) {
|
|
188
|
+
return {
|
|
189
|
+
decision: 'reject',
|
|
190
|
+
reason: 'Missing cost coverage cannot replace the current best-known version.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
decision: 'reject',
|
|
195
|
+
reason: 'Missing cost coverage loses on quality/pass-rate tie-breaks.',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!previousVersion) {
|
|
201
|
+
return { decision: 'keep', reason: 'First candidate that clears all required floors.' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const compare = compareScoreVectors(scoreVector, previousVersion.aggregate_score_vector);
|
|
205
|
+
if (compare >= 0) {
|
|
206
|
+
return {
|
|
207
|
+
decision: 'keep',
|
|
208
|
+
reason: 'Candidate improves or matches incumbent under score/tie-break rules.',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { decision: 'reject', reason: 'Candidate underperforms the current incumbent.' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function computeConvergenceState(versionLedger, runEntries = []) {
|
|
215
|
+
const versions = [...(versionLedger.versions || [])];
|
|
216
|
+
const recent = versions.slice(-3);
|
|
217
|
+
const plateau =
|
|
218
|
+
recent.length === 3 &&
|
|
219
|
+
recent.every(
|
|
220
|
+
(entry) =>
|
|
221
|
+
entry.aggregate_score_vector && entry.aggregate_score_vector.balanced_score != null,
|
|
222
|
+
) &&
|
|
223
|
+
Math.max(...recent.map((entry) => entry.aggregate_score_vector.balanced_score)) -
|
|
224
|
+
Math.min(...recent.map((entry) => entry.aggregate_score_vector.balanced_score)) <=
|
|
225
|
+
0.02;
|
|
226
|
+
|
|
227
|
+
const latest = versions[versions.length - 1];
|
|
228
|
+
const latestRuns = latest
|
|
229
|
+
? runEntries.filter((entry) => entry.version_id_under_test === latest.version_id)
|
|
230
|
+
: [];
|
|
231
|
+
const laneSuccess =
|
|
232
|
+
latestRuns.length > 0 &&
|
|
233
|
+
latestRuns.every(
|
|
234
|
+
(entry) => entry.quality >= 0.9 && entry.pass_rate != null && entry.pass_rate >= 0.9,
|
|
235
|
+
);
|
|
236
|
+
const bestKnownCost =
|
|
237
|
+
versions
|
|
238
|
+
.map(
|
|
239
|
+
(entry) =>
|
|
240
|
+
entry.aggregate_score_vector && entry.aggregate_score_vector.raw_inference_cost_usd,
|
|
241
|
+
)
|
|
242
|
+
.filter((value) => value != null)
|
|
243
|
+
.sort((a, b) => a - b)[0] ?? null;
|
|
244
|
+
const latestCost =
|
|
245
|
+
latest && latest.aggregate_score_vector
|
|
246
|
+
? latest.aggregate_score_vector.raw_inference_cost_usd
|
|
247
|
+
: null;
|
|
248
|
+
const costWithinBand =
|
|
249
|
+
bestKnownCost != null && latestCost != null && latestCost <= bestKnownCost * 1.1;
|
|
250
|
+
const successBand =
|
|
251
|
+
latest &&
|
|
252
|
+
latest.aggregate_score_vector &&
|
|
253
|
+
latest.aggregate_score_vector.quality >= 0.9 &&
|
|
254
|
+
latest.aggregate_score_vector.pass_rate >= 0.9 &&
|
|
255
|
+
laneSuccess &&
|
|
256
|
+
costWithinBand;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
plateau,
|
|
260
|
+
converged: Boolean(plateau || successBand),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function computeAggregateScore(laneResults, baseline) {
|
|
265
|
+
const quality = aggregateAverage(laneResults.map((result) => result.quality));
|
|
266
|
+
const passRate = aggregateAverage(laneResults.map((result) => result.pass_rate));
|
|
267
|
+
const rawCost = aggregateAverage(laneResults.map((result) => result.inference_cost_usd));
|
|
268
|
+
const costs = laneResults.map((result) =>
|
|
269
|
+
normalizeCostEfficiency(result.inference_cost_usd, baseline),
|
|
270
|
+
);
|
|
271
|
+
const costEfficiency = aggregateAverage(costs);
|
|
272
|
+
const balancedScore =
|
|
273
|
+
costEfficiency == null ? null : quality * 0.45 + passRate * 0.4 + costEfficiency * 0.15;
|
|
274
|
+
return {
|
|
275
|
+
quality,
|
|
276
|
+
pass_rate: passRate,
|
|
277
|
+
cost_efficiency: costEfficiency,
|
|
278
|
+
balanced_score: balancedScore,
|
|
279
|
+
raw_inference_cost_usd: rawCost,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildLaneResult({ lane, scenarioId, artifactDir, learningArtifactPath }) {
|
|
284
|
+
const gradePath = path.join(artifactDir, 'grade.json');
|
|
285
|
+
const metaPath = path.join(artifactDir, 'meta.json');
|
|
286
|
+
const sessionMetricsPath = path.join(artifactDir, 'session-metrics.json');
|
|
287
|
+
const probeStatePath = path.join(artifactDir, 'probe-state.json');
|
|
288
|
+
const recipePath = path.join(artifactDir, 'recipe.json');
|
|
289
|
+
const recipeCookPath = path.join(artifactDir, 'recipe-cook.json');
|
|
290
|
+
|
|
291
|
+
const grade = readJsonIfExists(gradePath);
|
|
292
|
+
const meta = readJsonIfExists(metaPath);
|
|
293
|
+
const sessionMetrics = readJsonIfExists(sessionMetricsPath);
|
|
294
|
+
const probeState = readJsonIfExists(probeStatePath);
|
|
295
|
+
const recipeCook = readJsonIfExists(recipeCookPath);
|
|
296
|
+
assert(fs.existsSync(recipePath), `Missing required recipe artifact: ${recipePath}`);
|
|
297
|
+
|
|
298
|
+
let qualitySemantic = grade && grade.recipe_semantic;
|
|
299
|
+
let qualityReasoning = (grade && grade.reasoning) || '';
|
|
300
|
+
if (!qualitySemantic) {
|
|
301
|
+
qualitySemantic = recipeCook ? 'ok' : 'bad';
|
|
302
|
+
qualityReasoning = recipeCook
|
|
303
|
+
? 'Fallback semantic score derived from recipe-cook artifact presence.'
|
|
304
|
+
: 'No grade or recipe-cook artifact available.';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const passRateInput = resolvePassRate(meta, { live_run_exit: null });
|
|
308
|
+
const liveRunExit =
|
|
309
|
+
meta && typeof meta.validate_exit === 'number' && meta.validate_exit >= 0
|
|
310
|
+
? meta.validate_exit
|
|
311
|
+
: null;
|
|
312
|
+
const inferenceCost = resolveInferenceCost(sessionMetrics);
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
lane,
|
|
316
|
+
scenario_id: scenarioId,
|
|
317
|
+
task_artifact_dir: artifactDir,
|
|
318
|
+
learning_artifact_path: learningArtifactPath,
|
|
319
|
+
recipe_path: recipePath,
|
|
320
|
+
recipe_cook_path: fs.existsSync(recipeCookPath) ? recipeCookPath : null,
|
|
321
|
+
quality_semantic: qualitySemantic,
|
|
322
|
+
quality_reasoning: qualityReasoning,
|
|
323
|
+
live_run_exit: liveRunExit,
|
|
324
|
+
pass_rate_input: passRateInput,
|
|
325
|
+
inference_cost_usd: inferenceCost,
|
|
326
|
+
source_artifacts: {
|
|
327
|
+
grade_path: fs.existsSync(gradePath) ? gradePath : null,
|
|
328
|
+
meta_path: fs.existsSync(metaPath) ? metaPath : null,
|
|
329
|
+
session_metrics_path: fs.existsSync(sessionMetricsPath) ? sessionMetricsPath : null,
|
|
330
|
+
probe_state_path: probeState ? probeStatePath : null,
|
|
331
|
+
},
|
|
332
|
+
quality: qualitySemanticToScore(qualitySemantic),
|
|
333
|
+
pass_rate: passRateInput,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
appendJsonl,
|
|
339
|
+
assert,
|
|
340
|
+
buildLaneResult,
|
|
341
|
+
compareScoreVectors,
|
|
342
|
+
computeAggregateScore,
|
|
343
|
+
computeConvergenceState,
|
|
344
|
+
decisionFromScores,
|
|
345
|
+
expandTemplate,
|
|
346
|
+
isRecipeCookSelfEditPath,
|
|
347
|
+
normalizeRepoPath,
|
|
348
|
+
normalizeCostEfficiency,
|
|
349
|
+
qualitySemanticToScore,
|
|
350
|
+
readJsonIfExists,
|
|
351
|
+
readJsonLines,
|
|
352
|
+
resolveInferenceCost,
|
|
353
|
+
resolvePassRate,
|
|
354
|
+
validateLearningArtifact,
|
|
355
|
+
writeJson,
|
|
356
|
+
};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const { expandTemplate } = require('./lib.cjs');
|
|
8
|
+
|
|
9
|
+
const TEMPLATE_PATH = path.resolve(__dirname, '../skills/recipe-cook/references/TASK.md');
|
|
10
|
+
const OVERLAYS = {
|
|
11
|
+
'example-browser': path.resolve(__dirname, '../skills/recipe-cook/repos/example-browser.md'),
|
|
12
|
+
'example-mobile': path.resolve(__dirname, '../skills/recipe-cook/repos/example-mobile.md'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function assert(condition, message) {
|
|
16
|
+
if (!condition) throw new Error(message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
20
|
+
const args = {
|
|
21
|
+
repo: '',
|
|
22
|
+
repoRoot: '',
|
|
23
|
+
sourceKind: '',
|
|
24
|
+
sourceRef: '',
|
|
25
|
+
sourceText: '',
|
|
26
|
+
outputDir: '',
|
|
27
|
+
runId: '',
|
|
28
|
+
wrapper: '',
|
|
29
|
+
ghRepo: '',
|
|
30
|
+
jiraBaseUrl: process.env.JIRA_BASE_URL || '',
|
|
31
|
+
jiraEmail: process.env.JIRA_EMAIL || '',
|
|
32
|
+
jiraToken: process.env.JIRA_API_TOKEN || '',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
36
|
+
switch (argv[i]) {
|
|
37
|
+
case '--repo':
|
|
38
|
+
args.repo = argv[++i] || '';
|
|
39
|
+
break;
|
|
40
|
+
case '--repo-root':
|
|
41
|
+
args.repoRoot = path.resolve(argv[++i] || '');
|
|
42
|
+
break;
|
|
43
|
+
case '--source-kind':
|
|
44
|
+
args.sourceKind = argv[++i] || '';
|
|
45
|
+
break;
|
|
46
|
+
case '--source-ref':
|
|
47
|
+
args.sourceRef = argv[++i] || '';
|
|
48
|
+
break;
|
|
49
|
+
case '--source-text':
|
|
50
|
+
args.sourceText = argv[++i] || '';
|
|
51
|
+
break;
|
|
52
|
+
case '--output-dir':
|
|
53
|
+
args.outputDir = path.resolve(argv[++i] || '');
|
|
54
|
+
break;
|
|
55
|
+
case '--run-id':
|
|
56
|
+
args.runId = argv[++i] || '';
|
|
57
|
+
break;
|
|
58
|
+
case '--wrapper':
|
|
59
|
+
args.wrapper = argv[++i] || '';
|
|
60
|
+
break;
|
|
61
|
+
case '--gh-repo':
|
|
62
|
+
args.ghRepo = argv[++i] || '';
|
|
63
|
+
break;
|
|
64
|
+
case '--jira-base-url':
|
|
65
|
+
args.jiraBaseUrl = argv[++i] || '';
|
|
66
|
+
break;
|
|
67
|
+
case '--jira-email':
|
|
68
|
+
args.jiraEmail = argv[++i] || '';
|
|
69
|
+
break;
|
|
70
|
+
case '--jira-token':
|
|
71
|
+
args.jiraToken = argv[++i] || '';
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function slugify(value) {
|
|
82
|
+
return (
|
|
83
|
+
String(value)
|
|
84
|
+
.trim()
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
87
|
+
.replace(/^-|-$/g, '') || 'run'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeFile(filePath, contents) {
|
|
92
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
93
|
+
fs.writeFileSync(filePath, contents, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeJson(filePath, value) {
|
|
97
|
+
writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function inferRepoName(repoRoot) {
|
|
101
|
+
const base = path.basename(repoRoot);
|
|
102
|
+
if (base.includes('browser')) return 'example-browser';
|
|
103
|
+
if (base.includes('mobile')) return 'example-mobile';
|
|
104
|
+
return base;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fetchPrBundle(sourceRef, ghRepo) {
|
|
108
|
+
assert(ghRepo, 'PR source kind requires --gh-repo <owner/name>');
|
|
109
|
+
const view = spawnSync(
|
|
110
|
+
'gh',
|
|
111
|
+
[
|
|
112
|
+
'pr',
|
|
113
|
+
'view',
|
|
114
|
+
sourceRef,
|
|
115
|
+
'--repo',
|
|
116
|
+
ghRepo,
|
|
117
|
+
'--json',
|
|
118
|
+
'title,body,headRefName,changedFiles,labels,author,mergeStateStatus,state,mergedAt,url',
|
|
119
|
+
],
|
|
120
|
+
{ encoding: 'utf8' },
|
|
121
|
+
);
|
|
122
|
+
assert(view.status === 0, `gh pr view failed: ${view.stderr || view.stdout}`);
|
|
123
|
+
const diff = spawnSync('gh', ['pr', 'diff', sourceRef, '--repo', ghRepo], {
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
126
|
+
});
|
|
127
|
+
assert(
|
|
128
|
+
diff.error?.code !== 'ENOBUFS',
|
|
129
|
+
`gh pr diff exceeded 20MB buffer for PR ${sourceRef}; stream to disk or split the source.`,
|
|
130
|
+
);
|
|
131
|
+
assert(diff.status === 0, `gh pr diff failed: ${diff.stderr || diff.stdout}`);
|
|
132
|
+
return [
|
|
133
|
+
`# Source Kind: PR`,
|
|
134
|
+
`# Source Ref: ${sourceRef}`,
|
|
135
|
+
'',
|
|
136
|
+
'## PR Metadata',
|
|
137
|
+
'```json',
|
|
138
|
+
view.stdout.trim(),
|
|
139
|
+
'```',
|
|
140
|
+
'',
|
|
141
|
+
'## PR Diff',
|
|
142
|
+
'```diff',
|
|
143
|
+
diff.stdout.trim(),
|
|
144
|
+
'```',
|
|
145
|
+
'',
|
|
146
|
+
].join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveJiraSource(sourceRef, jiraBaseUrl) {
|
|
150
|
+
const raw = String(sourceRef || '').trim();
|
|
151
|
+
const urlMatch = raw.match(/https?:\/\/[^/]+\/browse\/([A-Z][A-Z0-9]+-\d+)/i);
|
|
152
|
+
const issueKey = urlMatch ? urlMatch[1].toUpperCase() : raw.toUpperCase();
|
|
153
|
+
const browseUrl = urlMatch
|
|
154
|
+
? raw
|
|
155
|
+
: `${String(jiraBaseUrl || '').replace(/\/$/, '')}/browse/${issueKey}`;
|
|
156
|
+
return {
|
|
157
|
+
issueKey,
|
|
158
|
+
browseUrl,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fetchJiraBundle(sourceRef, jiraBaseUrl, jiraEmail, jiraToken) {
|
|
163
|
+
assert(jiraBaseUrl, 'Jira source kind requires --jira-base-url or JIRA_BASE_URL');
|
|
164
|
+
const { issueKey, browseUrl } = resolveJiraSource(sourceRef, jiraBaseUrl);
|
|
165
|
+
if (!(jiraEmail && jiraToken)) {
|
|
166
|
+
return [
|
|
167
|
+
`# Source Kind: Jira`,
|
|
168
|
+
`# Source Ref: ${issueKey}`,
|
|
169
|
+
`# Source URL: ${browseUrl}`,
|
|
170
|
+
'',
|
|
171
|
+
'## Jira Issue',
|
|
172
|
+
'',
|
|
173
|
+
'Jira API fetch unavailable from the current shell context.',
|
|
174
|
+
'Use the ticket URL above plus any local notes/branch context as the source of truth for the first iteration.',
|
|
175
|
+
'',
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
178
|
+
const url = `${jiraBaseUrl.replace(/\/$/, '')}/rest/api/3/issue/${issueKey}?expand=renderedFields`;
|
|
179
|
+
const res = spawnSync('curl', ['-s', '-u', `${jiraEmail}:${jiraToken}`, url], {
|
|
180
|
+
encoding: 'utf8',
|
|
181
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
182
|
+
});
|
|
183
|
+
assert(res.status === 0, `curl Jira fetch failed: ${res.stderr || res.stdout}`);
|
|
184
|
+
return [
|
|
185
|
+
`# Source Kind: Jira`,
|
|
186
|
+
`# Source Ref: ${issueKey}`,
|
|
187
|
+
`# Source URL: ${browseUrl}`,
|
|
188
|
+
'',
|
|
189
|
+
'## Jira Issue',
|
|
190
|
+
'```json',
|
|
191
|
+
res.stdout.trim(),
|
|
192
|
+
'```',
|
|
193
|
+
'',
|
|
194
|
+
].join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fetchTextBundle(sourceRef, sourceText) {
|
|
198
|
+
return [
|
|
199
|
+
`# Source Kind: Text`,
|
|
200
|
+
`# Source Ref: ${sourceRef || 'text'}`,
|
|
201
|
+
'',
|
|
202
|
+
'## Input Text',
|
|
203
|
+
'```text',
|
|
204
|
+
sourceText.trim(),
|
|
205
|
+
'```',
|
|
206
|
+
'',
|
|
207
|
+
].join('\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function fetchFileBundle(sourceRef) {
|
|
211
|
+
const filePath = path.resolve(sourceRef);
|
|
212
|
+
assert(fs.existsSync(filePath), `Source file does not exist: ${filePath}`);
|
|
213
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
214
|
+
return [
|
|
215
|
+
`# Source Kind: File`,
|
|
216
|
+
`# Source Ref: ${filePath}`,
|
|
217
|
+
'',
|
|
218
|
+
'## File Contents',
|
|
219
|
+
'```',
|
|
220
|
+
content.trim(),
|
|
221
|
+
'```',
|
|
222
|
+
'',
|
|
223
|
+
].join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function resolveTemplatePath() {
|
|
227
|
+
return TEMPLATE_PATH;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function main() {
|
|
231
|
+
const args = parseArgs();
|
|
232
|
+
assert(args.repoRoot, 'Missing --repo-root <path>');
|
|
233
|
+
const repoName = args.repo || inferRepoName(args.repoRoot);
|
|
234
|
+
assert(OVERLAYS[repoName], `Unsupported --repo / inferred repo: ${repoName}`);
|
|
235
|
+
assert(args.sourceKind, 'Missing --source-kind <pr|jira|text|file>');
|
|
236
|
+
|
|
237
|
+
let sourceBundle = '';
|
|
238
|
+
switch (args.sourceKind) {
|
|
239
|
+
case 'pr':
|
|
240
|
+
sourceBundle = fetchPrBundle(args.sourceRef, args.ghRepo);
|
|
241
|
+
break;
|
|
242
|
+
case 'jira':
|
|
243
|
+
sourceBundle = fetchJiraBundle(
|
|
244
|
+
args.sourceRef,
|
|
245
|
+
args.jiraBaseUrl,
|
|
246
|
+
args.jiraEmail,
|
|
247
|
+
args.jiraToken,
|
|
248
|
+
);
|
|
249
|
+
break;
|
|
250
|
+
case 'text':
|
|
251
|
+
sourceBundle = fetchTextBundle(args.sourceRef, args.sourceText);
|
|
252
|
+
break;
|
|
253
|
+
case 'file':
|
|
254
|
+
sourceBundle = fetchFileBundle(args.sourceRef);
|
|
255
|
+
break;
|
|
256
|
+
default:
|
|
257
|
+
throw new Error(`Unsupported --source-kind: ${args.sourceKind}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const runId =
|
|
261
|
+
args.runId ||
|
|
262
|
+
`${slugify(`${args.sourceKind}-${args.sourceRef || 'source'}`)}-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
263
|
+
const runDir =
|
|
264
|
+
args.outputDir || path.resolve(args.repoRoot, '.omx', 'artifacts', 'recipe-cook-runs', runId);
|
|
265
|
+
const artifactsDir = path.join(runDir, 'artifacts');
|
|
266
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
267
|
+
|
|
268
|
+
const sourceBundlePath = path.join(runDir, 'SOURCE-BUNDLE.md');
|
|
269
|
+
writeFile(sourceBundlePath, sourceBundle);
|
|
270
|
+
|
|
271
|
+
const templatePath = resolveTemplatePath();
|
|
272
|
+
const templateText = fs.readFileSync(templatePath, 'utf8');
|
|
273
|
+
const taskPath = path.join(runDir, 'TASK.md');
|
|
274
|
+
const taskText = expandTemplate(templateText, {
|
|
275
|
+
WRAPPER: args.wrapper || 'recipe-cook',
|
|
276
|
+
SCENARIO_ID: runId,
|
|
277
|
+
TARGET_REPO: repoName,
|
|
278
|
+
REPO_ROOT: args.repoRoot,
|
|
279
|
+
SOURCE_KIND: args.sourceKind,
|
|
280
|
+
SOURCE_REF: args.sourceRef || args.sourceKind,
|
|
281
|
+
SOURCE_ARTIFACT_DIR: '',
|
|
282
|
+
TASK_DIR: runDir,
|
|
283
|
+
ARTIFACT_DIR: artifactsDir,
|
|
284
|
+
SOURCE_BUNDLE_FILE: sourceBundlePath,
|
|
285
|
+
VALIDATION_MODE: 'pending',
|
|
286
|
+
});
|
|
287
|
+
writeFile(taskPath, taskText);
|
|
288
|
+
|
|
289
|
+
writeJson(path.join(runDir, 'run-meta.json'), {
|
|
290
|
+
run_id: runId,
|
|
291
|
+
wrapper: args.wrapper || 'recipe-cook',
|
|
292
|
+
repo: repoName,
|
|
293
|
+
repo_root: args.repoRoot,
|
|
294
|
+
source_kind: args.sourceKind,
|
|
295
|
+
source_ref: args.sourceRef,
|
|
296
|
+
source_bundle_path: sourceBundlePath,
|
|
297
|
+
task_path: taskPath,
|
|
298
|
+
artifacts_dir: artifactsDir,
|
|
299
|
+
gh_repo: args.ghRepo || null,
|
|
300
|
+
jira_base_url: args.jiraBaseUrl || null,
|
|
301
|
+
template_path: templatePath,
|
|
302
|
+
prepared_at: new Date().toISOString(),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
process.stdout.write(`${runDir}\n`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (require.main === module) {
|
|
309
|
+
main();
|
|
310
|
+
}
|