@lumpcode/recipes 0.0.12
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/LICENSE +17 -0
- package/README.md +153 -0
- package/dist/index.cjs +775 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +749 -0
- package/package.json +61 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var cliUtils = require('@lumpcode/cli-utils');
|
|
4
|
+
var core = require('@lumpcode/core');
|
|
5
|
+
var url = require('url');
|
|
6
|
+
var path = require('path');
|
|
7
|
+
var path$1 = require('node:path');
|
|
8
|
+
var node_url = require('node:url');
|
|
9
|
+
var fs = require('fs/promises');
|
|
10
|
+
var jsYaml = require('js-yaml');
|
|
11
|
+
|
|
12
|
+
/** Run a shell script via `sh -c` (portable on Unix-like systems and Git Bash on Windows). */
|
|
13
|
+
function shellCommand(script) {
|
|
14
|
+
return {
|
|
15
|
+
executable: 'sh',
|
|
16
|
+
args: ['-c', script],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeMaybePromGetter(maybePromGetter, defaultValue) {
|
|
21
|
+
if (typeof maybePromGetter === 'function') {
|
|
22
|
+
return maybePromGetter;
|
|
23
|
+
}
|
|
24
|
+
const value = maybePromGetter ?? defaultValue;
|
|
25
|
+
return () => value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function resolveContextName(index, count, contextName) {
|
|
29
|
+
return normalizeMaybePromGetter(contextName, (new Date()).toISOString().slice(0, 23).replace(/:/g, '').replace('.', '-'))({
|
|
30
|
+
index,
|
|
31
|
+
count,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function ephemeralContextListFn(options = {}) {
|
|
35
|
+
return async (input) => {
|
|
36
|
+
const count = await (normalizeMaybePromGetter(options.contextCount, 1)(input));
|
|
37
|
+
if (count <= 0) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const contextNamesSet = new Set();
|
|
41
|
+
return Promise.all(Array.from({ length: count }, async (_, index) => {
|
|
42
|
+
let name = await resolveContextName(index, count, options.contextName);
|
|
43
|
+
if (contextNamesSet.has(name)) {
|
|
44
|
+
name = `${name}-${index}`;
|
|
45
|
+
}
|
|
46
|
+
contextNamesSet.add(name);
|
|
47
|
+
const variables = await (normalizeMaybePromGetter(options.variables, {})({
|
|
48
|
+
contextName: name,
|
|
49
|
+
index,
|
|
50
|
+
count,
|
|
51
|
+
}));
|
|
52
|
+
return {
|
|
53
|
+
name,
|
|
54
|
+
variables,
|
|
55
|
+
};
|
|
56
|
+
}));
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const GET_RECURSIVE_STEPS_IS_OK_FLAG_KEY = '__getRecursiveSteps_isOk__';
|
|
61
|
+
function stepIndexDepth(stepIndex) {
|
|
62
|
+
return Array.isArray(stepIndex) ? stepIndex.length : 1;
|
|
63
|
+
}
|
|
64
|
+
/** Agent prompt(s) followed by a validation command, retried until checks pass or `maxIterations` is reached. */
|
|
65
|
+
function getRecursiveSteps({ maxIterations = 5, validationCommandFn = () => null, isValidationCommandResultOk = ({ commandSucceeded }) => commandSucceeded, getFirstSteps = () => [], currentIteration = 0, prevValidateCommandResult = null, prevValidateCommandDescriptor = null, contextRunStateIsOkFlagKey = GET_RECURSIVE_STEPS_IS_OK_FLAG_KEY, }) {
|
|
66
|
+
const firstSteps = getFirstSteps({
|
|
67
|
+
currentIteration,
|
|
68
|
+
prevValidateCommandResult,
|
|
69
|
+
prevValidateCommandDescriptor,
|
|
70
|
+
});
|
|
71
|
+
let thisIterValidateCommandResult = null;
|
|
72
|
+
let thisIterValidateCommandDescriptor = null;
|
|
73
|
+
return [
|
|
74
|
+
...cliUtils.normalizeSteps({
|
|
75
|
+
prompt: undefined,
|
|
76
|
+
jsSteps: firstSteps,
|
|
77
|
+
}),
|
|
78
|
+
{
|
|
79
|
+
async commandFn(input) {
|
|
80
|
+
if (stepIndexDepth(input.stepIndex) > maxIterations) {
|
|
81
|
+
return {
|
|
82
|
+
executable: 'echo',
|
|
83
|
+
args: ['Loop limit reached'],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!input.contextRunState[contextRunStateIsOkFlagKey]) {
|
|
87
|
+
const validateCommandDescriptor = await validationCommandFn({
|
|
88
|
+
...input,
|
|
89
|
+
currentIteration,
|
|
90
|
+
prevValidateCommandResult,
|
|
91
|
+
contextRunStateIsOkFlagKey,
|
|
92
|
+
});
|
|
93
|
+
thisIterValidateCommandDescriptor = validateCommandDescriptor || null;
|
|
94
|
+
return validateCommandDescriptor;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
},
|
|
98
|
+
postCommandExecFn(input) {
|
|
99
|
+
thisIterValidateCommandResult = input.commandResult;
|
|
100
|
+
input.contextRunState[contextRunStateIsOkFlagKey] = isValidationCommandResultOk({
|
|
101
|
+
...input,
|
|
102
|
+
currentIteration,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
continueOnError: currentIteration < maxIterations,
|
|
106
|
+
},
|
|
107
|
+
({ contextRunState, stepIndex }) => {
|
|
108
|
+
if (stepIndexDepth(stepIndex) > maxIterations) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
return !contextRunState[contextRunStateIsOkFlagKey]
|
|
112
|
+
? getRecursiveSteps({
|
|
113
|
+
maxIterations,
|
|
114
|
+
validationCommandFn,
|
|
115
|
+
isValidationCommandResultOk,
|
|
116
|
+
getFirstSteps,
|
|
117
|
+
currentIteration: currentIteration + 1,
|
|
118
|
+
prevValidateCommandResult: thisIterValidateCommandResult,
|
|
119
|
+
prevValidateCommandDescriptor: thisIterValidateCommandDescriptor,
|
|
120
|
+
contextRunStateIsOkFlagKey,
|
|
121
|
+
})
|
|
122
|
+
: [];
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatVerificationCommand(descriptor) {
|
|
128
|
+
if (!descriptor) {
|
|
129
|
+
return '(no verification command recorded)';
|
|
130
|
+
}
|
|
131
|
+
if (descriptor.executable === 'sh' && descriptor.args[0] === '-c' && descriptor.args.length === 2) {
|
|
132
|
+
return descriptor.args[1];
|
|
133
|
+
}
|
|
134
|
+
const command = [descriptor.executable, ...descriptor.args.map(core.shellSingleQuote)].join(' ');
|
|
135
|
+
if (!descriptor.env || Object.keys(descriptor.env).length === 0) {
|
|
136
|
+
return command;
|
|
137
|
+
}
|
|
138
|
+
const envPrefix = Object.entries(descriptor.env)
|
|
139
|
+
.map(([key, value]) => `${key}=${core.shellSingleQuote(value)}`)
|
|
140
|
+
.join(' ');
|
|
141
|
+
return `${envPrefix} ${command}`;
|
|
142
|
+
}
|
|
143
|
+
function defaultFixPrompt({ prevValidateCommandResult, prevValidateCommandDescriptor }) {
|
|
144
|
+
return [
|
|
145
|
+
'The verification step failed. Fix the issues and try again.',
|
|
146
|
+
'',
|
|
147
|
+
'Verification command:',
|
|
148
|
+
'',
|
|
149
|
+
formatVerificationCommand(prevValidateCommandDescriptor),
|
|
150
|
+
'',
|
|
151
|
+
'Verification output:',
|
|
152
|
+
'',
|
|
153
|
+
prevValidateCommandResult ?? '(no output captured)',
|
|
154
|
+
].join('\n');
|
|
155
|
+
}
|
|
156
|
+
function defaultFixSteps(input) {
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
promptFn() {
|
|
160
|
+
return defaultFixPrompt(input);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
/** Work steps, validation command, and optional fix steps — retried until checks pass or `maxIterations`. */
|
|
166
|
+
function retryUntilGreen({ steps, fixSteps, validationCommandFn, isValidationCommandResultOk, contextRunStateIsOkFlagKey, maxIterations, }) {
|
|
167
|
+
return getRecursiveSteps({
|
|
168
|
+
maxIterations,
|
|
169
|
+
validationCommandFn,
|
|
170
|
+
isValidationCommandResultOk,
|
|
171
|
+
contextRunStateIsOkFlagKey,
|
|
172
|
+
getFirstSteps({ currentIteration, prevValidateCommandResult, prevValidateCommandDescriptor }) {
|
|
173
|
+
if (currentIteration === 0) {
|
|
174
|
+
return steps;
|
|
175
|
+
}
|
|
176
|
+
return (fixSteps ?? defaultFixSteps)({
|
|
177
|
+
currentIteration,
|
|
178
|
+
prevValidateCommandResult,
|
|
179
|
+
prevValidateCommandDescriptor,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function lumpPathAndName(configUrl) {
|
|
186
|
+
const lumpName = path.basename(path.dirname(url.fileURLToPath(configUrl)));
|
|
187
|
+
const lumpPath = path.join('.lumpcode', 'lumps', lumpName);
|
|
188
|
+
return [
|
|
189
|
+
lumpPath,
|
|
190
|
+
lumpName,
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolves git project root from a lump `config.ts` module URL. */
|
|
195
|
+
function projectRootFromConfigUrl(configUrl) {
|
|
196
|
+
const configDir = path$1.dirname(node_url.fileURLToPath(configUrl));
|
|
197
|
+
return path$1.resolve(configDir, '../../..');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Fails the context when the artifact referenced by a context variable was not created. */
|
|
201
|
+
function requireArtifactStep(artifactPathVarName) {
|
|
202
|
+
return {
|
|
203
|
+
async commandFn({ context, workspacePath }) {
|
|
204
|
+
const artifactPath = context.variables[artifactPathVarName];
|
|
205
|
+
if (typeof artifactPath !== 'string' || artifactPath.trim() === '') {
|
|
206
|
+
throw new Error(`Missing context variable ${artifactPathVarName}`);
|
|
207
|
+
}
|
|
208
|
+
const fullPath = path$1.join(workspacePath, artifactPath);
|
|
209
|
+
const exists = await core.pathExists(fullPath);
|
|
210
|
+
if (!exists) {
|
|
211
|
+
throw new Error(`Expected artifact at ${artifactPath} was not created`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
executable: 'node',
|
|
215
|
+
args: ['-e', 'process.exit(0)'],
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertProjectRelativePath(filePath, label) {
|
|
222
|
+
if (path$1.isAbsolute(filePath)) {
|
|
223
|
+
throw new Error(`${label} must be project-root-relative, not absolute: ${filePath}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function resolveBacklogPaths(configUrl, overrides) {
|
|
227
|
+
const [lumpPath, lumpName] = lumpPathAndName(configUrl);
|
|
228
|
+
const backlogFilePath = overrides?.backlogFilePath ?? path$1.join(lumpPath, 'BACKLOG.yml');
|
|
229
|
+
const doneFilePath = overrides?.doneFilePath ?? path$1.join(lumpPath, 'DONE.yml');
|
|
230
|
+
assertProjectRelativePath(backlogFilePath, 'backlogFilePath');
|
|
231
|
+
assertProjectRelativePath(doneFilePath, 'doneFilePath');
|
|
232
|
+
return {
|
|
233
|
+
lumpPath,
|
|
234
|
+
lumpName,
|
|
235
|
+
backlogFilePath,
|
|
236
|
+
doneFilePath,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Moves a finished backlog item from BACKLOG.yml to DONE.yml after the context completes. */
|
|
241
|
+
const setTaskDoneStep = (input) => {
|
|
242
|
+
return {
|
|
243
|
+
async commandFn({ context, workspacePath }) {
|
|
244
|
+
const variables = context.variables;
|
|
245
|
+
const { backlogVarName, doneVarName } = input;
|
|
246
|
+
const baseBacklogFilePath = variables[backlogVarName];
|
|
247
|
+
const baseDoneFilePath = variables[doneVarName];
|
|
248
|
+
if (!baseBacklogFilePath || !baseDoneFilePath) {
|
|
249
|
+
throw new Error(`Backlog and done file paths are required`);
|
|
250
|
+
}
|
|
251
|
+
const backlogFilePath = path.join(workspacePath, baseBacklogFilePath);
|
|
252
|
+
const doneFilePath = path.join(workspacePath, baseDoneFilePath);
|
|
253
|
+
const backlog = await cliUtils.readYamlList(backlogFilePath);
|
|
254
|
+
const finishedItem = backlog.find((item) => item.name === variables.TASK_NAME);
|
|
255
|
+
if (finishedItem) {
|
|
256
|
+
const remaining = backlog.filter((item) => item.name !== variables.TASK_NAME);
|
|
257
|
+
await fs.writeFile(backlogFilePath, jsYaml.dump(remaining));
|
|
258
|
+
const done = await cliUtils.readYamlList(doneFilePath);
|
|
259
|
+
done.push({ ...finishedItem, completedAt: new Date().toISOString() });
|
|
260
|
+
await fs.writeFile(doneFilePath, jsYaml.dump(done));
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
executable: 'cat',
|
|
264
|
+
args: [doneFilePath],
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
continueOnError: true,
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function resolveImplValidateCommand(implValidateCommand) {
|
|
272
|
+
if (typeof implValidateCommand === 'function') {
|
|
273
|
+
return implValidateCommand;
|
|
274
|
+
}
|
|
275
|
+
if (typeof implValidateCommand === 'string') {
|
|
276
|
+
return (_input) => shellCommand(implValidateCommand);
|
|
277
|
+
}
|
|
278
|
+
return (_input) => implValidateCommand;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const CONTEXT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
282
|
+
function assertRecord(value, index) {
|
|
283
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
284
|
+
throw new Error(`Backlog item at index ${index} must be an object`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function assertStringField(record, field, index) {
|
|
288
|
+
const value = record[field];
|
|
289
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
290
|
+
throw new Error(`Backlog item at index ${index} requires non-empty string field "${field}"`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function assertNumberField(record, field, index) {
|
|
294
|
+
const value = record[field];
|
|
295
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
296
|
+
throw new Error(`Backlog item at index ${index} requires numeric field "${field}"`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Validates and normalizes one YAML backlog row; throws on invalid input. */
|
|
300
|
+
function validateBaseBacklogItem(raw, index) {
|
|
301
|
+
assertRecord(raw, index);
|
|
302
|
+
assertStringField(raw, 'name', index);
|
|
303
|
+
assertStringField(raw, 'task', index);
|
|
304
|
+
assertNumberField(raw, 'priority', index);
|
|
305
|
+
if (!CONTEXT_NAME_PATTERN.test(raw.name)) {
|
|
306
|
+
throw new Error(`Backlog item at index ${index} has invalid name "${raw.name}" (expected ^[a-zA-Z0-9_-]+$)`);
|
|
307
|
+
}
|
|
308
|
+
let dependsOn;
|
|
309
|
+
if (raw.dependsOn !== undefined) {
|
|
310
|
+
if (!Array.isArray(raw.dependsOn)) {
|
|
311
|
+
throw new Error(`Backlog item at index ${index} field "dependsOn" must be an array`);
|
|
312
|
+
}
|
|
313
|
+
for (const dep of raw.dependsOn) {
|
|
314
|
+
if (typeof dep !== 'string' || dep.trim() === '') {
|
|
315
|
+
throw new Error(`Backlog item at index ${index} field "dependsOn" must contain non-empty strings`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
dependsOn = raw.dependsOn;
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
name: raw.name,
|
|
322
|
+
task: raw.task,
|
|
323
|
+
priority: raw.priority,
|
|
324
|
+
dependsOn,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function ymlBacklogContexts({ backlogFilePath, parseItem, parseContext, }) {
|
|
329
|
+
return async () => {
|
|
330
|
+
const raw = await fs.readFile(backlogFilePath, 'utf-8');
|
|
331
|
+
const doc = jsYaml.load(raw);
|
|
332
|
+
if (!Array.isArray(doc)) {
|
|
333
|
+
throw new Error(`Backlog file ${backlogFilePath} must contain a YAML list`);
|
|
334
|
+
}
|
|
335
|
+
const allCtxs = await Promise.all(doc.map(async (rawItem, index) => {
|
|
336
|
+
const baseItem = validateBaseBacklogItem(rawItem, index);
|
|
337
|
+
const item = parseItem ? parseItem(baseItem, index, rawItem) : baseItem;
|
|
338
|
+
const { parsed, ignored } = parseContext
|
|
339
|
+
? await parseContext(item, index)
|
|
340
|
+
: { parsed: undefined, ignored: false };
|
|
341
|
+
if (ignored)
|
|
342
|
+
return null;
|
|
343
|
+
return {
|
|
344
|
+
name: item.name,
|
|
345
|
+
options: {
|
|
346
|
+
priority: item.priority,
|
|
347
|
+
dependsOnContexts: item.dependsOn,
|
|
348
|
+
},
|
|
349
|
+
variables: parsed?.variables ?? {},
|
|
350
|
+
...parsed,
|
|
351
|
+
};
|
|
352
|
+
}));
|
|
353
|
+
return allCtxs.filter((ctx) => !!ctx);
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function defineRecipe(recipe) {
|
|
358
|
+
return recipe;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const BACKLOG_STAGE_VAR = 'BACKLOG_STAGE';
|
|
362
|
+
const BACKLOG_TASK_NAME_VAR = 'TASK_NAME';
|
|
363
|
+
const BACKLOG_TASK_VAR = 'TASK';
|
|
364
|
+
const BACKLOG_FILE_VAR = 'BACKLOG_FILE';
|
|
365
|
+
const BACKLOG_DONE_FILE_VAR = 'DONE_FILE';
|
|
366
|
+
|
|
367
|
+
function isIgnoredResolution(resolution) {
|
|
368
|
+
return 'ignored' in resolution && resolution.ignored === true;
|
|
369
|
+
}
|
|
370
|
+
function buildStageSteps(stages, stageName) {
|
|
371
|
+
const stageDef = stages[stageName];
|
|
372
|
+
if (!stageDef) {
|
|
373
|
+
return [];
|
|
374
|
+
}
|
|
375
|
+
const normalized = cliUtils.normalizeSteps({
|
|
376
|
+
prompt: undefined,
|
|
377
|
+
jsSteps: stageDef.steps,
|
|
378
|
+
});
|
|
379
|
+
if (stageDef.completion === 'moveToDone') {
|
|
380
|
+
return [
|
|
381
|
+
...normalized,
|
|
382
|
+
setTaskDoneStep({
|
|
383
|
+
backlogVarName: BACKLOG_FILE_VAR,
|
|
384
|
+
doneVarName: BACKLOG_DONE_FILE_VAR,
|
|
385
|
+
}),
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
return normalized;
|
|
389
|
+
}
|
|
390
|
+
function backlog(options) {
|
|
391
|
+
const { configUrl, backlogFilePath: backlogFilePathOverride, doneFilePath: doneFilePathOverride, stages, parseItem, resolveItem, ...rest } = options;
|
|
392
|
+
const paths = resolveBacklogPaths(configUrl, {
|
|
393
|
+
backlogFilePath: backlogFilePathOverride,
|
|
394
|
+
doneFilePath: doneFilePathOverride,
|
|
395
|
+
});
|
|
396
|
+
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
397
|
+
const absoluteBacklogPath = path$1.join(projectRoot, paths.backlogFilePath);
|
|
398
|
+
return cliUtils.defineConfig({
|
|
399
|
+
getContextListFn: ymlBacklogContexts({
|
|
400
|
+
backlogFilePath: absoluteBacklogPath,
|
|
401
|
+
parseItem,
|
|
402
|
+
async parseContext(item) {
|
|
403
|
+
const resolution = await resolveItem({ item, paths });
|
|
404
|
+
if (isIgnoredResolution(resolution)) {
|
|
405
|
+
return { ignored: true };
|
|
406
|
+
}
|
|
407
|
+
const { stage, contextName, variables, additionalDependsOnContexts, } = resolution;
|
|
408
|
+
const dependsOnContexts = [
|
|
409
|
+
...(item.dependsOn ?? []),
|
|
410
|
+
...(additionalDependsOnContexts ?? []),
|
|
411
|
+
];
|
|
412
|
+
return {
|
|
413
|
+
parsed: {
|
|
414
|
+
name: contextName ?? item.name,
|
|
415
|
+
variables: {
|
|
416
|
+
[BACKLOG_TASK_NAME_VAR]: item.name,
|
|
417
|
+
[BACKLOG_TASK_VAR]: item.task,
|
|
418
|
+
[BACKLOG_FILE_VAR]: paths.backlogFilePath,
|
|
419
|
+
[BACKLOG_DONE_FILE_VAR]: paths.doneFilePath,
|
|
420
|
+
[BACKLOG_STAGE_VAR]: stage,
|
|
421
|
+
...variables,
|
|
422
|
+
},
|
|
423
|
+
options: {
|
|
424
|
+
priority: item.priority,
|
|
425
|
+
dependsOnContexts: dependsOnContexts.length > 0 ? dependsOnContexts : undefined,
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
},
|
|
430
|
+
}),
|
|
431
|
+
steps: [
|
|
432
|
+
({ context }) => {
|
|
433
|
+
const ctx = context;
|
|
434
|
+
const stageName = ctx.variables[BACKLOG_STAGE_VAR];
|
|
435
|
+
if (typeof stageName !== 'string') {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
return buildStageSteps(stages, stageName);
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
...rest,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const backlogRecipe = defineRecipe((options) => backlog(options));
|
|
445
|
+
|
|
446
|
+
const DEFAULT_IMPL_VALIDATE_COMMAND = [
|
|
447
|
+
'npm run build -w=@lumpcode/cli',
|
|
448
|
+
'npm run test -w=@lumpcode/cli',
|
|
449
|
+
].join(' && ');
|
|
450
|
+
const abstractionBacklog = defineRecipe((options) => {
|
|
451
|
+
const { implValidateCommand = DEFAULT_IMPL_VALIDATE_COMMAND, configUrl, ...rest } = options;
|
|
452
|
+
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
453
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
454
|
+
return backlog({
|
|
455
|
+
configUrl,
|
|
456
|
+
async resolveItem({ item, paths }) {
|
|
457
|
+
const itemPrdPath = path$1.join(paths.lumpPath, 'prds', `${item.name}.prd.md`);
|
|
458
|
+
const hasPrd = await core.pathExists(path$1.join(projectRoot, itemPrdPath));
|
|
459
|
+
if (!hasPrd) {
|
|
460
|
+
return { ignored: true };
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
stage: 'implementation',
|
|
464
|
+
variables: {
|
|
465
|
+
PRD_FILE: itemPrdPath,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
},
|
|
469
|
+
stages: {
|
|
470
|
+
implementation: {
|
|
471
|
+
completion: 'moveToDone',
|
|
472
|
+
steps: retryUntilGreen({
|
|
473
|
+
steps: [{
|
|
474
|
+
promptFn({ context: ctx }) {
|
|
475
|
+
const vars = ctx.variables;
|
|
476
|
+
const { PRD_FILE, TASK_NAME, TASK } = vars;
|
|
477
|
+
return `
|
|
478
|
+
Implement the abstraction described in @${PRD_FILE}.
|
|
479
|
+
|
|
480
|
+
Backlog item: ${TASK_NAME}
|
|
481
|
+
Task summary:
|
|
482
|
+
${TASK}
|
|
483
|
+
|
|
484
|
+
Requirements:
|
|
485
|
+
- Materialize the abstraction as a new util under packages/apps/cli/src/utils/<utilName>/ following existing conventions: main.ts (implementation), index.ts (re-export), unit.test.ts (unit tests), and a barrel export from packages/apps/cli/src/utils/index.ts.
|
|
486
|
+
- Refactor all call sites in packages/apps/cli to import the new util.
|
|
487
|
+
- Net line count must go down after the refactor (removed duplication minus new util code, excluding the new unit test file). Do not extract one-off logic or move code without deleting repetition.
|
|
488
|
+
- Include unit tests in unit.test.ts (match sibling utils in packages/apps/cli/src/utils/).
|
|
489
|
+
`.trim();
|
|
490
|
+
},
|
|
491
|
+
}],
|
|
492
|
+
validationCommandFn: runImplValidation,
|
|
493
|
+
}),
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
...rest,
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const abstractionFinder = defineRecipe((options) => {
|
|
501
|
+
const { maxPendingAbstractions = 5, scanDirectories, customPrompt, backlogFilePath, prdDirPath, doneFilePath, } = options;
|
|
502
|
+
return cliUtils.defineConfig({
|
|
503
|
+
...options,
|
|
504
|
+
getContextListFn: ephemeralContextListFn({
|
|
505
|
+
contextCount: maxPendingAbstractions,
|
|
506
|
+
variables: {
|
|
507
|
+
PRD_DIR_PATH: prdDirPath ?? '',
|
|
508
|
+
BACKLOG_FILE_PATH: backlogFilePath ?? '',
|
|
509
|
+
DONE_FILE_PATH: doneFilePath ?? '',
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
steps: buildFinderPrompt({ prdDirPath, backlogFilePath, doneFilePath, scanDirectories, customPrompt }),
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
function buildFinderPrompt({ prdDirPath, backlogFilePath, doneFilePath, scanDirectories, customPrompt, }) {
|
|
516
|
+
return customPrompt ? customPrompt() : `
|
|
517
|
+
Scan ${(scanDirectories || []).map((dir) => `@${dir}`).join(' and ') || 'the codebase'} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
|
|
518
|
+
|
|
519
|
+
Read @${backlogFilePath} and @${doneFilePath}. Do not propose abstractions whose util name already appears in either file.
|
|
520
|
+
|
|
521
|
+
Pick exactly one new abstraction that:
|
|
522
|
+
- Has a clear util name matching ^[a-zA-Z0-9_-]+$ that describes the pattern it captures.
|
|
523
|
+
- Would materialize as a new util under packages/apps/cli/src/utils/<utilName>/.
|
|
524
|
+
- Would shrink the codebase: refactoring all call sites in packages/apps/cli should reduce net line count (excluding new unit tests).
|
|
525
|
+
|
|
526
|
+
Add exactly one entry to @${backlogFilePath} with:
|
|
527
|
+
- task: a concise summary of the repeated pattern, proposed util name, and affected areas
|
|
528
|
+
- priority: max existing priority in BACKLOG.yml plus 1 (or 1 if the backlog is empty)
|
|
529
|
+
- dependsOn: optional list of util names from BACKLOG.yml or DONE.yml that must land first (only when clearly needed)
|
|
530
|
+
|
|
531
|
+
Write an implementation-ready PRD to @${prdDirPath}/<utilName>.prd.md for the same util name. The PRD should be self-contained and include:
|
|
532
|
+
- Problem statement and repeated pattern
|
|
533
|
+
- Goals and non-goals
|
|
534
|
+
- Proposed util API and affected files
|
|
535
|
+
- Acceptance criteria (including net line reduction and unit tests)
|
|
536
|
+
|
|
537
|
+
Do not implement code. Only edit @${backlogFilePath} (append one item) and create the PRD file.
|
|
538
|
+
`.trim();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const RESERVED_NAME_SUFFIXES = ['_prd', '_testPlan', '_tests_impl'];
|
|
542
|
+
function assertValidFeatureItemName(name) {
|
|
543
|
+
for (const suffix of RESERVED_NAME_SUFFIXES) {
|
|
544
|
+
if (name.endsWith(suffix)) {
|
|
545
|
+
throw new Error(`Backlog item name must not end with reserved suffix ${suffix}: ${name}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function featureContextName(itemName, stage) {
|
|
550
|
+
switch (stage) {
|
|
551
|
+
case 'makePrd':
|
|
552
|
+
return `${itemName}_prd`;
|
|
553
|
+
case 'makeTestPlan':
|
|
554
|
+
return `${itemName}_testPlan`;
|
|
555
|
+
case 'testImpl':
|
|
556
|
+
return `${itemName}_tests_impl`;
|
|
557
|
+
case 'implementation':
|
|
558
|
+
return itemName;
|
|
559
|
+
default: {
|
|
560
|
+
const _exhaustive = stage;
|
|
561
|
+
return _exhaustive;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, baseBranch) {
|
|
566
|
+
const prdFilePath = path$1.join(paths.lumpPath, 'prds', `${item.name}.prd.md`);
|
|
567
|
+
const testPlanFilePath = path$1.join(paths.lumpPath, 'testPlans', `${item.name}.test.md`);
|
|
568
|
+
const hasPrd = await core.pathExists(path$1.join(projectRoot, prdFilePath));
|
|
569
|
+
if (!hasPrd) {
|
|
570
|
+
if (item.manualPrd === true) {
|
|
571
|
+
return { ignored: true };
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
stage: 'makePrd',
|
|
575
|
+
contextName: featureContextName(item.name, 'makePrd'),
|
|
576
|
+
variables: { PRD_FILE: prdFilePath },
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const hasTestPlan = await core.pathExists(path$1.join(projectRoot, testPlanFilePath));
|
|
580
|
+
if (!hasTestPlan) {
|
|
581
|
+
return {
|
|
582
|
+
stage: 'makeTestPlan',
|
|
583
|
+
contextName: featureContextName(item.name, 'makeTestPlan'),
|
|
584
|
+
variables: {
|
|
585
|
+
PRD_FILE: prdFilePath,
|
|
586
|
+
TEST_PLAN_FILE: testPlanFilePath,
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const testsImplContextName = featureContextName(item.name, 'testImpl');
|
|
591
|
+
const testsImplStatus = await cliUtils.getContextStatus({
|
|
592
|
+
projectRoot,
|
|
593
|
+
contextName: testsImplContextName,
|
|
594
|
+
lumpName,
|
|
595
|
+
baseBranch,
|
|
596
|
+
});
|
|
597
|
+
if (testsImplStatus === 'finished') {
|
|
598
|
+
return {
|
|
599
|
+
stage: 'implementation',
|
|
600
|
+
contextName: featureContextName(item.name, 'implementation'),
|
|
601
|
+
variables: {
|
|
602
|
+
PRD_FILE: prdFilePath,
|
|
603
|
+
TEST_PLAN_FILE: testPlanFilePath,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
if (testsImplStatus === 'branchPushed') {
|
|
608
|
+
return { ignored: true };
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
stage: 'testImpl',
|
|
612
|
+
contextName: testsImplContextName,
|
|
613
|
+
variables: {
|
|
614
|
+
PRD_FILE: prdFilePath,
|
|
615
|
+
TEST_PLAN_FILE: testPlanFilePath,
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
const featureBacklog = defineRecipe((options) => {
|
|
620
|
+
const { configUrl, baseBranch, implValidateCommand, backlogFilePath, doneFilePath, ...rest } = options;
|
|
621
|
+
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
622
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
623
|
+
return backlog({
|
|
624
|
+
configUrl,
|
|
625
|
+
backlogFilePath,
|
|
626
|
+
doneFilePath,
|
|
627
|
+
baseBranch,
|
|
628
|
+
parseItem(baseItem, _index, raw) {
|
|
629
|
+
assertValidFeatureItemName(baseItem.name);
|
|
630
|
+
const record = raw;
|
|
631
|
+
if (record.manualPrd !== undefined && typeof record.manualPrd !== 'boolean') {
|
|
632
|
+
throw new Error(`Backlog item "${baseItem.name}" field "manualPrd" must be a boolean`);
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
...baseItem,
|
|
636
|
+
manualPrd: record.manualPrd === true ? true : undefined,
|
|
637
|
+
};
|
|
638
|
+
},
|
|
639
|
+
async resolveItem({ item, paths }) {
|
|
640
|
+
return resolveFeatureBacklogItem(item, paths, projectRoot, paths.lumpName, baseBranch);
|
|
641
|
+
},
|
|
642
|
+
stages: {
|
|
643
|
+
makePrd: {
|
|
644
|
+
completion: 'keepPending',
|
|
645
|
+
steps: [
|
|
646
|
+
{
|
|
647
|
+
promptFn({ context: ctx }) {
|
|
648
|
+
const vars = ctx.variables;
|
|
649
|
+
const { BACKLOG_FILE, TASK_NAME, TASK, PRD_FILE } = vars;
|
|
650
|
+
return `
|
|
651
|
+
Write a product requirements document (PRD) for the following Lumpcode backlog item from @${BACKLOG_FILE}.
|
|
652
|
+
|
|
653
|
+
Task name: ${TASK_NAME}
|
|
654
|
+
|
|
655
|
+
Task:
|
|
656
|
+
${TASK}
|
|
657
|
+
|
|
658
|
+
Save the PRD to @${PRD_FILE}. Do not edit @${BACKLOG_FILE}.
|
|
659
|
+
|
|
660
|
+
The PRD should be self-contained and implementation-ready. Include:
|
|
661
|
+
- Problem statement and motivation
|
|
662
|
+
- Goals and non-goals
|
|
663
|
+
- User stories / use cases
|
|
664
|
+
- Docs updates (if relevant)
|
|
665
|
+
- Proposed behavior and UX (for CLI work, include command syntax where relevant)
|
|
666
|
+
- Technical approach and affected packages or docs
|
|
667
|
+
- Acceptance criteria
|
|
668
|
+
|
|
669
|
+
Do not implement the feature — only create the PRD markdown file.
|
|
670
|
+
The PRD should not contain any testing strategy details.
|
|
671
|
+
`.trim();
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
requireArtifactStep('PRD_FILE'),
|
|
675
|
+
],
|
|
676
|
+
},
|
|
677
|
+
makeTestPlan: {
|
|
678
|
+
completion: 'keepPending',
|
|
679
|
+
steps: [
|
|
680
|
+
{
|
|
681
|
+
promptFn({ context: ctx }) {
|
|
682
|
+
const vars = ctx.variables;
|
|
683
|
+
const { BACKLOG_FILE, TASK_NAME, TASK, PRD_FILE, TEST_PLAN_FILE } = vars;
|
|
684
|
+
return `
|
|
685
|
+
Write a test plan for the following Lumpcode backlog item from @${BACKLOG_FILE}.
|
|
686
|
+
|
|
687
|
+
Task name: ${TASK_NAME}
|
|
688
|
+
Task:
|
|
689
|
+
${TASK}
|
|
690
|
+
|
|
691
|
+
The PRD for this task is @${PRD_FILE}. The test plan should match the requirements of the PRD.
|
|
692
|
+
|
|
693
|
+
Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${BACKLOG_FILE} nor @${PRD_FILE}.
|
|
694
|
+
|
|
695
|
+
The test plan should be self-contained and implementation-ready. Include:
|
|
696
|
+
- Test cases
|
|
697
|
+
- Test data
|
|
698
|
+
- Test expectations
|
|
699
|
+
- Test implementation details
|
|
700
|
+
`.trim();
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
requireArtifactStep('TEST_PLAN_FILE'),
|
|
704
|
+
],
|
|
705
|
+
},
|
|
706
|
+
testImpl: {
|
|
707
|
+
completion: 'keepPending',
|
|
708
|
+
steps: [
|
|
709
|
+
{
|
|
710
|
+
promptFn({ context: ctx }) {
|
|
711
|
+
const vars = ctx.variables;
|
|
712
|
+
const { BACKLOG_FILE, TASK_NAME, TASK, PRD_FILE, TEST_PLAN_FILE } = vars;
|
|
713
|
+
return `
|
|
714
|
+
Write a test implementation for the following Lumpcode backlog item from @${BACKLOG_FILE}.
|
|
715
|
+
|
|
716
|
+
Task name: ${TASK_NAME}
|
|
717
|
+
Task:
|
|
718
|
+
${TASK}
|
|
719
|
+
|
|
720
|
+
Follow the test plan in @${TEST_PLAN_FILE}.
|
|
721
|
+
The PRD for this task is @${PRD_FILE}.
|
|
722
|
+
`.trim();
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
},
|
|
727
|
+
implementation: {
|
|
728
|
+
completion: 'moveToDone',
|
|
729
|
+
steps: retryUntilGreen({
|
|
730
|
+
steps: [
|
|
731
|
+
{
|
|
732
|
+
promptFn({ context: ctx }) {
|
|
733
|
+
const vars = ctx.variables;
|
|
734
|
+
const { PRD_FILE, TEST_PLAN_FILE } = vars;
|
|
735
|
+
return `
|
|
736
|
+
Implement the feature described in @${PRD_FILE}.
|
|
737
|
+
The tests have already been implemented according to the test plan in @${TEST_PLAN_FILE}.
|
|
738
|
+
The implementation should make the tests pass. Do not edit any test file.
|
|
739
|
+
`.trim();
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
validationCommandFn: runImplValidation,
|
|
744
|
+
}),
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
...rest,
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
exports.BACKLOG_DONE_FILE_VAR = BACKLOG_DONE_FILE_VAR;
|
|
752
|
+
exports.BACKLOG_FILE_VAR = BACKLOG_FILE_VAR;
|
|
753
|
+
exports.BACKLOG_STAGE_VAR = BACKLOG_STAGE_VAR;
|
|
754
|
+
exports.BACKLOG_TASK_NAME_VAR = BACKLOG_TASK_NAME_VAR;
|
|
755
|
+
exports.BACKLOG_TASK_VAR = BACKLOG_TASK_VAR;
|
|
756
|
+
exports.abstractionBacklog = abstractionBacklog;
|
|
757
|
+
exports.abstractionFinder = abstractionFinder;
|
|
758
|
+
exports.backlog = backlog;
|
|
759
|
+
exports.backlogRecipe = backlogRecipe;
|
|
760
|
+
exports.defineRecipe = defineRecipe;
|
|
761
|
+
exports.ephemeralContextListFn = ephemeralContextListFn;
|
|
762
|
+
exports.featureBacklog = featureBacklog;
|
|
763
|
+
exports.getRecursiveSteps = getRecursiveSteps;
|
|
764
|
+
exports.lumpPathAndName = lumpPathAndName;
|
|
765
|
+
exports.normalizeMaybePromGetter = normalizeMaybePromGetter;
|
|
766
|
+
exports.projectRootFromConfigUrl = projectRootFromConfigUrl;
|
|
767
|
+
exports.requireArtifactStep = requireArtifactStep;
|
|
768
|
+
exports.resolveBacklogPaths = resolveBacklogPaths;
|
|
769
|
+
exports.resolveFeatureBacklogItem = resolveFeatureBacklogItem;
|
|
770
|
+
exports.resolveImplValidateCommand = resolveImplValidateCommand;
|
|
771
|
+
exports.retryUntilGreen = retryUntilGreen;
|
|
772
|
+
exports.setTaskDoneStep = setTaskDoneStep;
|
|
773
|
+
exports.shellCommand = shellCommand;
|
|
774
|
+
exports.validateBaseBacklogItem = validateBaseBacklogItem;
|
|
775
|
+
exports.ymlBacklogContexts = ymlBacklogContexts;
|