@lumpcode/recipes 0.0.12 → 0.0.14
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/README.md +25 -8
- package/dist/index.cjs +239 -113
- package/dist/index.d.ts +45 -40
- package/dist/index.js +237 -113
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,10 +8,26 @@ Private monorepo workspace for now (same rollout path as `@lumpcode/cli-utils`).
|
|
|
8
8
|
|
|
9
9
|
| Recipe | Export | Use when |
|
|
10
10
|
|--------|--------|----------|
|
|
11
|
-
| **backlog** | `backlog` | Generic
|
|
11
|
+
| **backlog** | `backlog` | Generic folder backlog with a typed stage map and per-item stage resolution |
|
|
12
12
|
| **featureBacklog** | `featureBacklog` | Feature items with PRD → test plan → test implementation → implementation |
|
|
13
13
|
| **abstractionFinder** | `abstractionFinder` | Ephemeral contexts that scan for duplicated CLI utils and append one backlog item + PRD per run |
|
|
14
|
-
| **abstractionBacklog** | `abstractionBacklog` |
|
|
14
|
+
| **abstractionBacklog** | `abstractionBacklog` | Folder backlog items with PRDs — implement abstraction with verify-until-green, then move item to completed/ |
|
|
15
|
+
|
|
16
|
+
## Backlog layout
|
|
17
|
+
|
|
18
|
+
Each lump stores backlog items under `backlogItems/`:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
.lumpcode/lumps/<lump>/backlogItems/
|
|
22
|
+
todo/<name>/desc.yml
|
|
23
|
+
todo/<name>/prd.md # optional until makePrd / finder writes it
|
|
24
|
+
todo/<name>/testPlan.md # featureBacklog only; optional until makeTestPlan
|
|
25
|
+
completed/<name>/desc.yml # includes completedAt after move-to-done
|
|
26
|
+
completed/<name>/prd.md # moves with the folder
|
|
27
|
+
completed/<name>/testPlan.md
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`desc.yml` is a single YAML object with `name`, `task`, `priority`, optional `dependsOn`, and recipe-specific fields (e.g. `manualPrd` for featureBacklog).
|
|
15
31
|
|
|
16
32
|
## Kit
|
|
17
33
|
|
|
@@ -21,8 +37,9 @@ Flat helpers under `src/kit/` (re-exported from the package root):
|
|
|
21
37
|
- `getRecursiveSteps` — agent step(s) + validation command, retry until pass
|
|
22
38
|
- `retryUntilGreen` — opinionated wrapper over `getRecursiveSteps` with default fix prompt
|
|
23
39
|
- `ephemeralContextListFn` — N fresh synthetic contexts per run (`contextCount`, index-aware names)
|
|
24
|
-
- `
|
|
25
|
-
- `
|
|
40
|
+
- `folderBacklogContexts` — `getContextListFn` from `backlogItems/todo/` with optional per-item parsing
|
|
41
|
+
- `folderSetTaskDoneStep` — move finished item folder from `todo/` to `completed/` after a context completes
|
|
42
|
+
- `ymlBacklogContexts` / `setTaskDoneStep` — **deprecated** YAML-list helpers (warn once, still work)
|
|
26
43
|
- `resolveImplValidateCommand` — string, descriptor, or fn → `ValidationCommandFn`
|
|
27
44
|
- `shellCommand` — `sh -c` helper for validation commands
|
|
28
45
|
|
|
@@ -47,7 +64,9 @@ export default backlog({
|
|
|
47
64
|
});
|
|
48
65
|
```
|
|
49
66
|
|
|
50
|
-
`resolveItem` returns `{ stage, contextName?, variables?, additionalDependsOnContexts? }` or `{ ignored: true }`. Terminal stages with `completion: 'moveToDone'` append `
|
|
67
|
+
`resolveItem` returns `{ stage, contextName?, variables?, additionalDependsOnContexts? }` or `{ ignored: true }`. Terminal stages with `completion: 'moveToDone'` append `folderSetTaskDoneStep`.
|
|
68
|
+
|
|
69
|
+
Context variables injected by `backlog`: `TASK_NAME`, `TASK`, `BACKLOG_ITEMS_DIR`, `BACKLOG_ITEM_DIR`, `BACKLOG_STAGE`.
|
|
51
70
|
|
|
52
71
|
## Examples
|
|
53
72
|
|
|
@@ -90,9 +109,7 @@ export default {
|
|
|
90
109
|
...abstractionFinder({
|
|
91
110
|
maxPendingAbstractions: 5,
|
|
92
111
|
scanDirectories: ['packages/apps/cli'],
|
|
93
|
-
|
|
94
|
-
doneFilePath: '.lumpcode/lumps/abstractionImplementer/DONE.yml',
|
|
95
|
-
prdDirPath: '.lumpcode/lumps/abstractionImplementer/prds',
|
|
112
|
+
backlogItemsDir: '.lumpcode/lumps/abstractionImplementer/backlogItems',
|
|
96
113
|
command: 'cursor',
|
|
97
114
|
lumpVariables: { model: 'composer-2.5' },
|
|
98
115
|
discoveryBranch: 'dev',
|
package/dist/index.cjs
CHANGED
|
@@ -225,22 +225,188 @@ function assertProjectRelativePath(filePath, label) {
|
|
|
225
225
|
}
|
|
226
226
|
function resolveBacklogPaths(configUrl, overrides) {
|
|
227
227
|
const [lumpPath, lumpName] = lumpPathAndName(configUrl);
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
assertProjectRelativePath(backlogFilePath, 'backlogFilePath');
|
|
231
|
-
assertProjectRelativePath(doneFilePath, 'doneFilePath');
|
|
228
|
+
const backlogItemsDir = overrides?.backlogItemsDir ?? path$1.join(lumpPath, 'backlogItems');
|
|
229
|
+
assertProjectRelativePath(backlogItemsDir, 'backlogItemsDir');
|
|
232
230
|
return {
|
|
233
231
|
lumpPath,
|
|
234
232
|
lumpName,
|
|
235
|
-
|
|
236
|
-
doneFilePath,
|
|
233
|
+
backlogItemsDir,
|
|
237
234
|
};
|
|
238
235
|
}
|
|
239
236
|
|
|
237
|
+
const CONTEXT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
238
|
+
function assertRecord(value, location) {
|
|
239
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
240
|
+
throw new Error(`Backlog item ${location} must be an object`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function assertStringField(record, field, location) {
|
|
244
|
+
const value = record[field];
|
|
245
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
246
|
+
throw new Error(`Backlog item ${location} requires non-empty string field "${field}"`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function assertNumberField(record, field, location) {
|
|
250
|
+
const value = record[field];
|
|
251
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
252
|
+
throw new Error(`Backlog item ${location} requires numeric field "${field}"`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Validates and normalizes one backlog item; throws on invalid input. */
|
|
256
|
+
function validateBaseBacklogItem(raw, location) {
|
|
257
|
+
assertRecord(raw, location);
|
|
258
|
+
assertStringField(raw, 'name', location);
|
|
259
|
+
assertStringField(raw, 'task', location);
|
|
260
|
+
assertNumberField(raw, 'priority', location);
|
|
261
|
+
if (!CONTEXT_NAME_PATTERN.test(raw.name)) {
|
|
262
|
+
throw new Error(`Backlog item ${location} has invalid name "${raw.name}" (expected ^[a-zA-Z0-9_-]+$)`);
|
|
263
|
+
}
|
|
264
|
+
let dependsOn;
|
|
265
|
+
if (raw.dependsOn !== undefined) {
|
|
266
|
+
if (!Array.isArray(raw.dependsOn)) {
|
|
267
|
+
throw new Error(`Backlog item ${location} field "dependsOn" must be an array`);
|
|
268
|
+
}
|
|
269
|
+
for (const dep of raw.dependsOn) {
|
|
270
|
+
if (typeof dep !== 'string' || dep.trim() === '') {
|
|
271
|
+
throw new Error(`Backlog item ${location} field "dependsOn" must contain non-empty strings`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
dependsOn = raw.dependsOn;
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
name: raw.name,
|
|
278
|
+
task: raw.task,
|
|
279
|
+
priority: raw.priority,
|
|
280
|
+
dependsOn,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function listTodoFolderNames(todoDir) {
|
|
285
|
+
try {
|
|
286
|
+
const entries = await fs.readdir(todoDir, { withFileTypes: true });
|
|
287
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const err = error;
|
|
291
|
+
if (err.code === 'ENOENT') {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
298
|
+
return async () => {
|
|
299
|
+
const todoDir = path$1.join(backlogItemsDir, 'todo');
|
|
300
|
+
const folderNames = await listTodoFolderNames(todoDir);
|
|
301
|
+
const discovered = await Promise.all(folderNames.map(async (folderName) => {
|
|
302
|
+
const descPath = path$1.join(todoDir, folderName, 'desc.yml');
|
|
303
|
+
let rawText;
|
|
304
|
+
try {
|
|
305
|
+
rawText = await fs.readFile(descPath, 'utf-8');
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
const err = error;
|
|
309
|
+
if (err.code === 'ENOENT') {
|
|
310
|
+
throw new Error(`Backlog item folder "${folderName}" is missing desc.yml at ${descPath}`);
|
|
311
|
+
}
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
const raw = jsYaml.load(rawText);
|
|
315
|
+
const baseItem = validateBaseBacklogItem(raw, `in folder "${folderName}"`);
|
|
316
|
+
if (baseItem.name !== folderName) {
|
|
317
|
+
throw new Error(`Backlog item folder "${folderName}" desc.yml name "${baseItem.name}" must match folder name`);
|
|
318
|
+
}
|
|
319
|
+
const item = parseItem ? parseItem(baseItem, folderName, raw) : baseItem;
|
|
320
|
+
return { item, folderName };
|
|
321
|
+
}));
|
|
322
|
+
discovered.sort((a, b) => {
|
|
323
|
+
if (a.item.priority !== b.item.priority) {
|
|
324
|
+
return a.item.priority - b.item.priority;
|
|
325
|
+
}
|
|
326
|
+
return a.item.name.localeCompare(b.item.name);
|
|
327
|
+
});
|
|
328
|
+
const allCtxs = await Promise.all(discovered.map(async ({ item, folderName }) => {
|
|
329
|
+
const { parsed, ignored } = parseContext
|
|
330
|
+
? await parseContext(item, folderName)
|
|
331
|
+
: { parsed: undefined, ignored: false };
|
|
332
|
+
if (ignored) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
name: item.name,
|
|
337
|
+
options: {
|
|
338
|
+
priority: item.priority,
|
|
339
|
+
dependsOnContexts: item.dependsOn,
|
|
340
|
+
},
|
|
341
|
+
variables: parsed?.variables ?? {},
|
|
342
|
+
...parsed,
|
|
343
|
+
};
|
|
344
|
+
}));
|
|
345
|
+
return allCtxs.filter((ctx) => !!ctx);
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isPlainObject(value) {
|
|
350
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
351
|
+
}
|
|
352
|
+
/** Moves a finished backlog item folder from todo/ to completed/ after the context completes. */
|
|
353
|
+
const folderSetTaskDoneStep = (input) => {
|
|
354
|
+
const nameVarName = input.nameVarName ?? 'TASK_NAME';
|
|
355
|
+
return {
|
|
356
|
+
async commandFn({ context, workspacePath }) {
|
|
357
|
+
const variables = context.variables;
|
|
358
|
+
const itemsDirRelative = variables[input.itemsDirVarName];
|
|
359
|
+
const taskName = variables[nameVarName];
|
|
360
|
+
if (!itemsDirRelative || !taskName) {
|
|
361
|
+
throw new Error('Backlog items directory and task name are required');
|
|
362
|
+
}
|
|
363
|
+
const itemsDir = path$1.join(workspacePath, itemsDirRelative);
|
|
364
|
+
const fromDir = path$1.join(itemsDir, 'todo', taskName);
|
|
365
|
+
const toDir = path$1.join(itemsDir, 'completed', taskName);
|
|
366
|
+
const descPath = path$1.join(fromDir, 'desc.yml');
|
|
367
|
+
const completedDescPath = path$1.join(toDir, 'desc.yml');
|
|
368
|
+
if (!(await core.pathExists(fromDir))) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
if (await core.pathExists(toDir)) {
|
|
372
|
+
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName}": already exists at ${toDir}`);
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const rawText = await fs.readFile(descPath, 'utf-8');
|
|
376
|
+
const raw = jsYaml.load(rawText);
|
|
377
|
+
if (!isPlainObject(raw)) {
|
|
378
|
+
throw new Error(`Backlog desc.yml at ${descPath} must contain a YAML object`);
|
|
379
|
+
}
|
|
380
|
+
const updated = {
|
|
381
|
+
...raw,
|
|
382
|
+
completedAt: new Date().toISOString(),
|
|
383
|
+
};
|
|
384
|
+
await fs.mkdir(path$1.join(itemsDir, 'completed'), { recursive: true });
|
|
385
|
+
await fs.rename(fromDir, toDir);
|
|
386
|
+
await fs.writeFile(completedDescPath, jsYaml.dump(updated));
|
|
387
|
+
return {
|
|
388
|
+
executable: 'cat',
|
|
389
|
+
args: [completedDescPath],
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
continueOnError: true,
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
let setTaskDoneDeprecatedWarned = false;
|
|
397
|
+
function warnSetTaskDoneDeprecated() {
|
|
398
|
+
if (setTaskDoneDeprecatedWarned) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
setTaskDoneDeprecatedWarned = true;
|
|
402
|
+
console.warn('[lumpcode/recipes] setTaskDoneStep is deprecated; use folderSetTaskDoneStep. ' +
|
|
403
|
+
'YAML backlog helpers will be removed in a future major version.');
|
|
404
|
+
}
|
|
240
405
|
/** Moves a finished backlog item from BACKLOG.yml to DONE.yml after the context completes. */
|
|
241
406
|
const setTaskDoneStep = (input) => {
|
|
242
407
|
return {
|
|
243
408
|
async commandFn({ context, workspacePath }) {
|
|
409
|
+
warnSetTaskDoneDeprecated();
|
|
244
410
|
const variables = context.variables;
|
|
245
411
|
const { backlogVarName, doneVarName } = input;
|
|
246
412
|
const baseBacklogFilePath = variables[backlogVarName];
|
|
@@ -278,62 +444,25 @@ function resolveImplValidateCommand(implValidateCommand) {
|
|
|
278
444
|
return (_input) => implValidateCommand;
|
|
279
445
|
}
|
|
280
446
|
|
|
281
|
-
|
|
282
|
-
function
|
|
283
|
-
if (
|
|
284
|
-
|
|
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}"`);
|
|
447
|
+
let ymlBacklogDeprecatedWarned = false;
|
|
448
|
+
function warnYmlBacklogDeprecated() {
|
|
449
|
+
if (ymlBacklogDeprecatedWarned) {
|
|
450
|
+
return;
|
|
297
451
|
}
|
|
452
|
+
ymlBacklogDeprecatedWarned = true;
|
|
453
|
+
console.warn('[lumpcode/recipes] ymlBacklogContexts is deprecated; use folderBacklogContexts. ' +
|
|
454
|
+
'YAML backlog helpers will be removed in a future major version.');
|
|
298
455
|
}
|
|
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
456
|
function ymlBacklogContexts({ backlogFilePath, parseItem, parseContext, }) {
|
|
329
457
|
return async () => {
|
|
458
|
+
warnYmlBacklogDeprecated();
|
|
330
459
|
const raw = await fs.readFile(backlogFilePath, 'utf-8');
|
|
331
460
|
const doc = jsYaml.load(raw);
|
|
332
461
|
if (!Array.isArray(doc)) {
|
|
333
462
|
throw new Error(`Backlog file ${backlogFilePath} must contain a YAML list`);
|
|
334
463
|
}
|
|
335
464
|
const allCtxs = await Promise.all(doc.map(async (rawItem, index) => {
|
|
336
|
-
const baseItem = validateBaseBacklogItem(rawItem, index);
|
|
465
|
+
const baseItem = validateBaseBacklogItem(rawItem, `at index ${index}`);
|
|
337
466
|
const item = parseItem ? parseItem(baseItem, index, rawItem) : baseItem;
|
|
338
467
|
const { parsed, ignored } = parseContext
|
|
339
468
|
? await parseContext(item, index)
|
|
@@ -361,8 +490,8 @@ function defineRecipe(recipe) {
|
|
|
361
490
|
const BACKLOG_STAGE_VAR = 'BACKLOG_STAGE';
|
|
362
491
|
const BACKLOG_TASK_NAME_VAR = 'TASK_NAME';
|
|
363
492
|
const BACKLOG_TASK_VAR = 'TASK';
|
|
364
|
-
const
|
|
365
|
-
const
|
|
493
|
+
const BACKLOG_ITEMS_DIR_VAR = 'BACKLOG_ITEMS_DIR';
|
|
494
|
+
const BACKLOG_ITEM_DIR_VAR = 'BACKLOG_ITEM_DIR';
|
|
366
495
|
|
|
367
496
|
function isIgnoredResolution(resolution) {
|
|
368
497
|
return 'ignored' in resolution && resolution.ignored === true;
|
|
@@ -379,27 +508,25 @@ function buildStageSteps(stages, stageName) {
|
|
|
379
508
|
if (stageDef.completion === 'moveToDone') {
|
|
380
509
|
return [
|
|
381
510
|
...normalized,
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
doneVarName: BACKLOG_DONE_FILE_VAR,
|
|
511
|
+
folderSetTaskDoneStep({
|
|
512
|
+
itemsDirVarName: BACKLOG_ITEMS_DIR_VAR,
|
|
385
513
|
}),
|
|
386
514
|
];
|
|
387
515
|
}
|
|
388
516
|
return normalized;
|
|
389
517
|
}
|
|
390
518
|
function backlog(options) {
|
|
391
|
-
const { configUrl,
|
|
519
|
+
const { configUrl, backlogItemsDir: backlogItemsDirOverride, stages, parseItem, resolveItem, ...rest } = options;
|
|
392
520
|
const paths = resolveBacklogPaths(configUrl, {
|
|
393
|
-
|
|
394
|
-
doneFilePath: doneFilePathOverride,
|
|
521
|
+
backlogItemsDir: backlogItemsDirOverride,
|
|
395
522
|
});
|
|
396
523
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
397
|
-
const
|
|
524
|
+
const absoluteBacklogItemsDir = path$1.join(projectRoot, paths.backlogItemsDir);
|
|
398
525
|
return cliUtils.defineConfig({
|
|
399
|
-
getContextListFn:
|
|
400
|
-
|
|
526
|
+
getContextListFn: folderBacklogContexts({
|
|
527
|
+
backlogItemsDir: absoluteBacklogItemsDir,
|
|
401
528
|
parseItem,
|
|
402
|
-
async parseContext(item) {
|
|
529
|
+
async parseContext(item, folderName) {
|
|
403
530
|
const resolution = await resolveItem({ item, paths });
|
|
404
531
|
if (isIgnoredResolution(resolution)) {
|
|
405
532
|
return { ignored: true };
|
|
@@ -409,14 +536,15 @@ function backlog(options) {
|
|
|
409
536
|
...(item.dependsOn ?? []),
|
|
410
537
|
...(additionalDependsOnContexts ?? []),
|
|
411
538
|
];
|
|
539
|
+
const backlogItemDir = path$1.join(paths.backlogItemsDir, 'todo', folderName);
|
|
412
540
|
return {
|
|
413
541
|
parsed: {
|
|
414
542
|
name: contextName ?? item.name,
|
|
415
543
|
variables: {
|
|
416
544
|
[BACKLOG_TASK_NAME_VAR]: item.name,
|
|
417
545
|
[BACKLOG_TASK_VAR]: item.task,
|
|
418
|
-
[
|
|
419
|
-
[
|
|
546
|
+
[BACKLOG_ITEMS_DIR_VAR]: paths.backlogItemsDir,
|
|
547
|
+
[BACKLOG_ITEM_DIR_VAR]: backlogItemDir,
|
|
420
548
|
[BACKLOG_STAGE_VAR]: stage,
|
|
421
549
|
...variables,
|
|
422
550
|
},
|
|
@@ -443,18 +571,14 @@ function backlog(options) {
|
|
|
443
571
|
}
|
|
444
572
|
const backlogRecipe = defineRecipe((options) => backlog(options));
|
|
445
573
|
|
|
446
|
-
const DEFAULT_IMPL_VALIDATE_COMMAND = [
|
|
447
|
-
'npm run build -w=@lumpcode/cli',
|
|
448
|
-
'npm run test -w=@lumpcode/cli',
|
|
449
|
-
].join(' && ');
|
|
450
574
|
const abstractionBacklog = defineRecipe((options) => {
|
|
451
|
-
const { implValidateCommand
|
|
575
|
+
const { implValidateCommand, configUrl, implSteps, ...rest } = options;
|
|
452
576
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
453
|
-
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
577
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand ?? 'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
454
578
|
return backlog({
|
|
455
579
|
configUrl,
|
|
456
580
|
async resolveItem({ item, paths }) {
|
|
457
|
-
const itemPrdPath = path$1.join(paths.
|
|
581
|
+
const itemPrdPath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'prd.md');
|
|
458
582
|
const hasPrd = await core.pathExists(path$1.join(projectRoot, itemPrdPath));
|
|
459
583
|
if (!hasPrd) {
|
|
460
584
|
return { ignored: true };
|
|
@@ -470,7 +594,7 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
470
594
|
implementation: {
|
|
471
595
|
completion: 'moveToDone',
|
|
472
596
|
steps: retryUntilGreen({
|
|
473
|
-
steps: [{
|
|
597
|
+
steps: implSteps ?? [{
|
|
474
598
|
promptFn({ context: ctx }) {
|
|
475
599
|
const vars = ctx.variables;
|
|
476
600
|
const { PRD_FILE, TASK_NAME, TASK } = vars;
|
|
@@ -482,10 +606,10 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
482
606
|
${TASK}
|
|
483
607
|
|
|
484
608
|
Requirements:
|
|
485
|
-
- Materialize the abstraction as a new util
|
|
486
|
-
- Refactor all call sites
|
|
609
|
+
- Materialize the abstraction as a new util following existing conventions in the codebase.
|
|
610
|
+
- Refactor all call sites to import the new util.
|
|
487
611
|
- 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
|
|
612
|
+
- Include unit tests for the new util.
|
|
489
613
|
`.trim();
|
|
490
614
|
},
|
|
491
615
|
}],
|
|
@@ -498,43 +622,44 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
498
622
|
});
|
|
499
623
|
|
|
500
624
|
const abstractionFinder = defineRecipe((options) => {
|
|
501
|
-
const { maxPendingAbstractions = 5, scanDirectories, customPrompt,
|
|
625
|
+
const { maxPendingAbstractions = 5, scanDirectories, customPrompt, backlogItemsDir, } = options; // TODO : check if pending abstcations in backlog are less than maxPendingAbstractions
|
|
502
626
|
return cliUtils.defineConfig({
|
|
503
627
|
...options,
|
|
504
628
|
getContextListFn: ephemeralContextListFn({
|
|
505
629
|
contextCount: maxPendingAbstractions,
|
|
506
630
|
variables: {
|
|
507
|
-
|
|
508
|
-
BACKLOG_FILE_PATH: backlogFilePath ?? '',
|
|
509
|
-
DONE_FILE_PATH: doneFilePath ?? '',
|
|
631
|
+
BACKLOG_ITEMS_DIR: backlogItemsDir,
|
|
510
632
|
},
|
|
511
633
|
}),
|
|
512
|
-
steps: buildFinderPrompt({
|
|
634
|
+
steps: buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt }),
|
|
513
635
|
});
|
|
514
636
|
});
|
|
515
|
-
function buildFinderPrompt({
|
|
637
|
+
function buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt, }) {
|
|
516
638
|
return customPrompt ? customPrompt() : `
|
|
517
639
|
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
640
|
|
|
519
|
-
|
|
641
|
+
List existing backlog item names under @${backlogItemsDir}/todo/ and @${backlogItemsDir}/completed/. Do not propose abstractions whose util name already appears in either directory.
|
|
520
642
|
|
|
521
643
|
Pick exactly one new abstraction that:
|
|
522
644
|
- Has a clear util name matching ^[a-zA-Z0-9_-]+$ that describes the pattern it captures.
|
|
523
645
|
- Would materialize as a new util under packages/apps/cli/src/utils/<utilName>/.
|
|
524
646
|
- Would shrink the codebase: refactoring all call sites in packages/apps/cli should reduce net line count (excluding new unit tests).
|
|
525
647
|
|
|
526
|
-
|
|
527
|
-
-
|
|
528
|
-
-
|
|
529
|
-
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
-
|
|
533
|
-
-
|
|
534
|
-
-
|
|
535
|
-
-
|
|
536
|
-
|
|
537
|
-
|
|
648
|
+
Create exactly one new backlog item folder at @${backlogItemsDir}/todo/<utilName>/ with:
|
|
649
|
+
- desc.yml containing:
|
|
650
|
+
- name: <utilName> (must match folder name)
|
|
651
|
+
- task: a concise summary of the repeated pattern, proposed util name, and affected areas
|
|
652
|
+
- priority: max existing priority in todo/ plus 1 (or 1 if todo/ is empty)
|
|
653
|
+
- dependsOn: optional list of util names from todo/ or completed/ that must land first (only when clearly needed)
|
|
654
|
+
- prd.md: an implementation-ready PRD for the same util name. The PRD should be self-contained and include:
|
|
655
|
+
- Problem statement and repeated pattern
|
|
656
|
+
- Goals and non-goals
|
|
657
|
+
- Proposed util API and affected files
|
|
658
|
+
- Acceptance criteria (including net line reduction and unit tests)
|
|
659
|
+
|
|
660
|
+
Do not implement code. Only create @${backlogItemsDir}/todo/<utilName>/desc.yml and @${backlogItemsDir}/todo/<utilName>/prd.md.
|
|
661
|
+
|
|
662
|
+
Do not take too much time looking for every possible abstraction. Once you found a good abstraction, stop and create the backlog item.
|
|
538
663
|
`.trim();
|
|
539
664
|
}
|
|
540
665
|
|
|
@@ -563,8 +688,8 @@ function featureContextName(itemName, stage) {
|
|
|
563
688
|
}
|
|
564
689
|
}
|
|
565
690
|
async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, baseBranch) {
|
|
566
|
-
const prdFilePath = path$1.join(paths.
|
|
567
|
-
const testPlanFilePath = path$1.join(paths.
|
|
691
|
+
const prdFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'prd.md');
|
|
692
|
+
const testPlanFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'testPlan.md');
|
|
568
693
|
const hasPrd = await core.pathExists(path$1.join(projectRoot, prdFilePath));
|
|
569
694
|
if (!hasPrd) {
|
|
570
695
|
if (item.manualPrd === true) {
|
|
@@ -617,15 +742,14 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
617
742
|
};
|
|
618
743
|
}
|
|
619
744
|
const featureBacklog = defineRecipe((options) => {
|
|
620
|
-
const { configUrl, baseBranch, implValidateCommand,
|
|
745
|
+
const { configUrl, baseBranch, implValidateCommand, backlogItemsDir, ...rest } = options;
|
|
621
746
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
622
|
-
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
747
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand ?? 'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
623
748
|
return backlog({
|
|
624
749
|
configUrl,
|
|
625
|
-
|
|
626
|
-
doneFilePath,
|
|
750
|
+
backlogItemsDir,
|
|
627
751
|
baseBranch,
|
|
628
|
-
parseItem(baseItem,
|
|
752
|
+
parseItem(baseItem, _folderName, raw) {
|
|
629
753
|
assertValidFeatureItemName(baseItem.name);
|
|
630
754
|
const record = raw;
|
|
631
755
|
if (record.manualPrd !== undefined && typeof record.manualPrd !== 'boolean') {
|
|
@@ -646,16 +770,16 @@ const featureBacklog = defineRecipe((options) => {
|
|
|
646
770
|
{
|
|
647
771
|
promptFn({ context: ctx }) {
|
|
648
772
|
const vars = ctx.variables;
|
|
649
|
-
const {
|
|
773
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, PRD_FILE } = vars;
|
|
650
774
|
return `
|
|
651
|
-
Write a product requirements document (PRD) for the following
|
|
775
|
+
Write a product requirements document (PRD) for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
652
776
|
|
|
653
777
|
Task name: ${TASK_NAME}
|
|
654
778
|
|
|
655
779
|
Task:
|
|
656
780
|
${TASK}
|
|
657
781
|
|
|
658
|
-
Save the PRD to @${PRD_FILE}. Do not edit @${
|
|
782
|
+
Save the PRD to @${PRD_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
659
783
|
|
|
660
784
|
The PRD should be self-contained and implementation-ready. Include:
|
|
661
785
|
- Problem statement and motivation
|
|
@@ -680,9 +804,9 @@ The PRD should not contain any testing strategy details.
|
|
|
680
804
|
{
|
|
681
805
|
promptFn({ context: ctx }) {
|
|
682
806
|
const vars = ctx.variables;
|
|
683
|
-
const {
|
|
807
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, PRD_FILE, TEST_PLAN_FILE } = vars;
|
|
684
808
|
return `
|
|
685
|
-
Write a test plan for the following
|
|
809
|
+
Write a test plan for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
686
810
|
|
|
687
811
|
Task name: ${TASK_NAME}
|
|
688
812
|
Task:
|
|
@@ -690,7 +814,7 @@ ${TASK}
|
|
|
690
814
|
|
|
691
815
|
The PRD for this task is @${PRD_FILE}. The test plan should match the requirements of the PRD.
|
|
692
816
|
|
|
693
|
-
Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${
|
|
817
|
+
Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${PRD_FILE}.
|
|
694
818
|
|
|
695
819
|
The test plan should be self-contained and implementation-ready. Include:
|
|
696
820
|
- Test cases
|
|
@@ -709,9 +833,9 @@ The test plan should be self-contained and implementation-ready. Include:
|
|
|
709
833
|
{
|
|
710
834
|
promptFn({ context: ctx }) {
|
|
711
835
|
const vars = ctx.variables;
|
|
712
|
-
const {
|
|
836
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, PRD_FILE, TEST_PLAN_FILE } = vars;
|
|
713
837
|
return `
|
|
714
|
-
Write a test implementation for the following
|
|
838
|
+
Write a test implementation for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
715
839
|
|
|
716
840
|
Task name: ${TASK_NAME}
|
|
717
841
|
Task:
|
|
@@ -748,8 +872,8 @@ The implementation should make the tests pass. Do not edit any test file.
|
|
|
748
872
|
});
|
|
749
873
|
});
|
|
750
874
|
|
|
751
|
-
exports.
|
|
752
|
-
exports.
|
|
875
|
+
exports.BACKLOG_ITEMS_DIR_VAR = BACKLOG_ITEMS_DIR_VAR;
|
|
876
|
+
exports.BACKLOG_ITEM_DIR_VAR = BACKLOG_ITEM_DIR_VAR;
|
|
753
877
|
exports.BACKLOG_STAGE_VAR = BACKLOG_STAGE_VAR;
|
|
754
878
|
exports.BACKLOG_TASK_NAME_VAR = BACKLOG_TASK_NAME_VAR;
|
|
755
879
|
exports.BACKLOG_TASK_VAR = BACKLOG_TASK_VAR;
|
|
@@ -760,6 +884,8 @@ exports.backlogRecipe = backlogRecipe;
|
|
|
760
884
|
exports.defineRecipe = defineRecipe;
|
|
761
885
|
exports.ephemeralContextListFn = ephemeralContextListFn;
|
|
762
886
|
exports.featureBacklog = featureBacklog;
|
|
887
|
+
exports.folderBacklogContexts = folderBacklogContexts;
|
|
888
|
+
exports.folderSetTaskDoneStep = folderSetTaskDoneStep;
|
|
763
889
|
exports.getRecursiveSteps = getRecursiveSteps;
|
|
764
890
|
exports.lumpPathAndName = lumpPathAndName;
|
|
765
891
|
exports.normalizeMaybePromGetter = normalizeMaybePromGetter;
|
package/dist/index.d.ts
CHANGED
|
@@ -71,25 +71,14 @@ declare function projectRootFromConfigUrl(configUrl: string | URL): string;
|
|
|
71
71
|
/** Fails the context when the artifact referenced by a context variable was not created. */
|
|
72
72
|
declare function requireArtifactStep(artifactPathVarName: string): Step;
|
|
73
73
|
|
|
74
|
-
type BacklogPaths
|
|
74
|
+
type BacklogPaths = {
|
|
75
75
|
lumpPath: string;
|
|
76
76
|
lumpName: string;
|
|
77
|
-
|
|
78
|
-
doneFilePath: string;
|
|
77
|
+
backlogItemsDir: string;
|
|
79
78
|
};
|
|
80
79
|
declare function resolveBacklogPaths(configUrl: string | URL, overrides?: {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}): BacklogPaths$1;
|
|
84
|
-
|
|
85
|
-
/** Moves a finished backlog item from BACKLOG.yml to DONE.yml after the context completes. */
|
|
86
|
-
declare const setTaskDoneStep: (input: {
|
|
87
|
-
backlogVarName: string;
|
|
88
|
-
doneVarName: string;
|
|
89
|
-
}) => Step;
|
|
90
|
-
|
|
91
|
-
type ImplValidateCommand = string | CommandDescriptor | ValidationCommandFn;
|
|
92
|
-
declare function resolveImplValidateCommand(implValidateCommand: ImplValidateCommand): ValidationCommandFn;
|
|
80
|
+
backlogItemsDir?: string;
|
|
81
|
+
}): BacklogPaths;
|
|
93
82
|
|
|
94
83
|
type Recipe<Options, V extends LumpVariables = LumpVariables> = (options: Options) => LumpJsConfig<V>;
|
|
95
84
|
declare function defineRecipe<Options, V extends LumpVariables = LumpVariables>(recipe: Recipe<Options, V>): Recipe<Options, V>;
|
|
@@ -104,6 +93,31 @@ type DoneBacklogItem<T extends BaseBacklogItem = BaseBacklogItem> = T & {
|
|
|
104
93
|
completedAt: string;
|
|
105
94
|
};
|
|
106
95
|
|
|
96
|
+
type FolderBacklogContextsOptions<Item extends BaseBacklogItem = BaseBacklogItem> = {
|
|
97
|
+
backlogItemsDir: string;
|
|
98
|
+
parseItem?: (item: BaseBacklogItem, folderName: string, raw: unknown) => Item;
|
|
99
|
+
parseContext?: (item: Item, folderName: string) => MaybePromise$1<{
|
|
100
|
+
parsed?: Partial<Context>;
|
|
101
|
+
ignored?: boolean;
|
|
102
|
+
}>;
|
|
103
|
+
};
|
|
104
|
+
declare function folderBacklogContexts<Item extends BaseBacklogItem = BaseBacklogItem>({ backlogItemsDir, parseItem, parseContext, }: FolderBacklogContextsOptions<Item>): GetContextListFn;
|
|
105
|
+
|
|
106
|
+
/** Moves a finished backlog item folder from todo/ to completed/ after the context completes. */
|
|
107
|
+
declare const folderSetTaskDoneStep: (input: {
|
|
108
|
+
itemsDirVarName: string;
|
|
109
|
+
nameVarName?: string;
|
|
110
|
+
}) => Step;
|
|
111
|
+
|
|
112
|
+
/** Moves a finished backlog item from BACKLOG.yml to DONE.yml after the context completes. */
|
|
113
|
+
declare const setTaskDoneStep: (input: {
|
|
114
|
+
backlogVarName: string;
|
|
115
|
+
doneVarName: string;
|
|
116
|
+
}) => Step;
|
|
117
|
+
|
|
118
|
+
type ImplValidateCommand = string | CommandDescriptor | ValidationCommandFn;
|
|
119
|
+
declare function resolveImplValidateCommand(implValidateCommand: ImplValidateCommand): ValidationCommandFn;
|
|
120
|
+
|
|
107
121
|
type YmlBacklogContextsOptions<Item extends BaseBacklogItem = BaseBacklogItem> = {
|
|
108
122
|
backlogFilePath: string;
|
|
109
123
|
parseItem?: (item: BaseBacklogItem, index: number, raw: unknown) => Item;
|
|
@@ -114,23 +128,22 @@ type YmlBacklogContextsOptions<Item extends BaseBacklogItem = BaseBacklogItem> =
|
|
|
114
128
|
};
|
|
115
129
|
declare function ymlBacklogContexts<Item extends BaseBacklogItem = BaseBacklogItem>({ backlogFilePath, parseItem, parseContext, }: YmlBacklogContextsOptions<Item>): GetContextListFn;
|
|
116
130
|
|
|
117
|
-
/** Validates and normalizes one
|
|
118
|
-
declare function validateBaseBacklogItem(raw: unknown,
|
|
131
|
+
/** Validates and normalizes one backlog item; throws on invalid input. */
|
|
132
|
+
declare function validateBaseBacklogItem(raw: unknown, location: string): BaseBacklogItem;
|
|
119
133
|
|
|
120
134
|
type AbstractionBacklogOptions = {
|
|
121
135
|
implValidateCommand?: ValidationCommandFn | string;
|
|
122
136
|
/** Lump config module URL — pass `import.meta.url` from `config.ts`. */
|
|
123
137
|
configUrl: string | URL;
|
|
138
|
+
implSteps?: LumpJsConfig['steps'];
|
|
124
139
|
} & Omit<LumpJsConfig, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt' | 'steps'>;
|
|
125
140
|
declare const abstractionBacklog: Recipe<AbstractionBacklogOptions>;
|
|
126
141
|
|
|
127
142
|
type AbstractionFinderOptions = {
|
|
128
|
-
scanDirectories
|
|
143
|
+
scanDirectories?: string[];
|
|
129
144
|
customPrompt?(): string;
|
|
130
145
|
maxPendingAbstractions?: number;
|
|
131
|
-
|
|
132
|
-
doneFilePath?: string;
|
|
133
|
-
prdDirPath?: string;
|
|
146
|
+
backlogItemsDir: string;
|
|
134
147
|
} & LumpJsConfig;
|
|
135
148
|
declare const abstractionFinder: Recipe<AbstractionFinderOptions>;
|
|
136
149
|
|
|
@@ -138,12 +151,6 @@ type BacklogStageDefinition = {
|
|
|
138
151
|
steps: NonNullable<LumpJsConfig['steps']>;
|
|
139
152
|
completion: 'keepPending' | 'moveToDone';
|
|
140
153
|
};
|
|
141
|
-
type BacklogPaths = {
|
|
142
|
-
lumpPath: string;
|
|
143
|
-
lumpName: string;
|
|
144
|
-
backlogFilePath: string;
|
|
145
|
-
doneFilePath: string;
|
|
146
|
-
};
|
|
147
154
|
type BacklogItemResolution<StageName extends string> = {
|
|
148
155
|
ignored: true;
|
|
149
156
|
} | {
|
|
@@ -154,10 +161,9 @@ type BacklogItemResolution<StageName extends string> = {
|
|
|
154
161
|
};
|
|
155
162
|
type BacklogOptions<Item extends BaseBacklogItem, Stages extends Record<string, BacklogStageDefinition>> = {
|
|
156
163
|
configUrl: string | URL;
|
|
157
|
-
|
|
158
|
-
doneFilePath?: string;
|
|
164
|
+
backlogItemsDir?: string;
|
|
159
165
|
stages: Stages;
|
|
160
|
-
parseItem?: (item: BaseBacklogItem,
|
|
166
|
+
parseItem?: (item: BaseBacklogItem, folderName: string, raw: unknown) => Item;
|
|
161
167
|
resolveItem(input: {
|
|
162
168
|
item: Item;
|
|
163
169
|
paths: BacklogPaths;
|
|
@@ -166,8 +172,8 @@ type BacklogOptions<Item extends BaseBacklogItem, Stages extends Record<string,
|
|
|
166
172
|
declare const BACKLOG_STAGE_VAR = "BACKLOG_STAGE";
|
|
167
173
|
declare const BACKLOG_TASK_NAME_VAR = "TASK_NAME";
|
|
168
174
|
declare const BACKLOG_TASK_VAR = "TASK";
|
|
169
|
-
declare const
|
|
170
|
-
declare const
|
|
175
|
+
declare const BACKLOG_ITEMS_DIR_VAR = "BACKLOG_ITEMS_DIR";
|
|
176
|
+
declare const BACKLOG_ITEM_DIR_VAR = "BACKLOG_ITEM_DIR";
|
|
171
177
|
|
|
172
178
|
declare function backlog<Item extends BaseBacklogItem, Stages extends Record<string, BacklogStageDefinition>>(options: BacklogOptions<Item, Stages>): LumpJsConfig;
|
|
173
179
|
declare const backlogRecipe: Recipe<BacklogOptions<BaseBacklogItem, Record<string, BacklogStageDefinition>>>;
|
|
@@ -179,8 +185,8 @@ type FeatureBacklogStage = 'makePrd' | 'makeTestPlan' | 'testImpl' | 'implementa
|
|
|
179
185
|
type FeatureBacklogContextVariables = {
|
|
180
186
|
TASK_NAME: string;
|
|
181
187
|
TASK: string;
|
|
182
|
-
|
|
183
|
-
|
|
188
|
+
BACKLOG_ITEMS_DIR: string;
|
|
189
|
+
BACKLOG_ITEM_DIR: string;
|
|
184
190
|
BACKLOG_STAGE: FeatureBacklogStage;
|
|
185
191
|
PRD_FILE?: string;
|
|
186
192
|
TEST_PLAN_FILE?: string;
|
|
@@ -188,12 +194,11 @@ type FeatureBacklogContextVariables = {
|
|
|
188
194
|
type FeatureBacklogOptions = {
|
|
189
195
|
configUrl: string | URL;
|
|
190
196
|
baseBranch: string;
|
|
191
|
-
implValidateCommand
|
|
192
|
-
|
|
193
|
-
doneFilePath?: string;
|
|
197
|
+
implValidateCommand?: ValidationCommandFn | string;
|
|
198
|
+
backlogItemsDir?: string;
|
|
194
199
|
} & Omit<LumpJsConfig, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt' | 'steps' | 'baseBranch'>;
|
|
195
|
-
declare function resolveFeatureBacklogItem(item: FeatureBacklogItem, paths: BacklogPaths
|
|
200
|
+
declare function resolveFeatureBacklogItem(item: FeatureBacklogItem, paths: BacklogPaths, projectRoot: string, lumpName: string, baseBranch: string): Promise<BacklogItemResolution<FeatureBacklogStage>>;
|
|
196
201
|
declare const featureBacklog: Recipe<FeatureBacklogOptions>;
|
|
197
202
|
|
|
198
|
-
export {
|
|
199
|
-
export type { AbstractionBacklogOptions, AbstractionFinderOptions, BacklogItemResolution, BacklogOptions, BacklogPaths
|
|
203
|
+
export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, lumpPathAndName, normalizeMaybePromGetter, projectRootFromConfigUrl, requireArtifactStep, resolveBacklogPaths, resolveFeatureBacklogItem, resolveImplValidateCommand, retryUntilGreen, setTaskDoneStep, shellCommand, validateBaseBacklogItem, ymlBacklogContexts };
|
|
204
|
+
export type { AbstractionBacklogOptions, AbstractionFinderOptions, BacklogItemResolution, BacklogOptions, BacklogPaths, BacklogStageDefinition, BaseBacklogItem, DoneBacklogItem, EphemeralContextListFnOptions, FeatureBacklogContextVariables, FeatureBacklogItem, FeatureBacklogOptions, FeatureBacklogStage, FolderBacklogContextsOptions, GetFirstStepsInput, GetRecursiveStepsOptions, ImplValidateCommand, IsValidationCommandResultOkInput, MaybePromGetter, Recipe, RetryUntilGreenInput, StepIndex, ValidationCommandFn, ValidationCommandFnInput, YmlBacklogContextsOptions };
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import path$1 from 'node:path';
|
|
6
6
|
import { fileURLToPath as fileURLToPath$1 } from 'node:url';
|
|
7
7
|
import fs from 'fs/promises';
|
|
8
|
-
import {
|
|
8
|
+
import { load, dump } from 'js-yaml';
|
|
9
9
|
|
|
10
10
|
/** Run a shell script via `sh -c` (portable on Unix-like systems and Git Bash on Windows). */
|
|
11
11
|
function shellCommand(script) {
|
|
@@ -223,22 +223,188 @@ function assertProjectRelativePath(filePath, label) {
|
|
|
223
223
|
}
|
|
224
224
|
function resolveBacklogPaths(configUrl, overrides) {
|
|
225
225
|
const [lumpPath, lumpName] = lumpPathAndName(configUrl);
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
assertProjectRelativePath(backlogFilePath, 'backlogFilePath');
|
|
229
|
-
assertProjectRelativePath(doneFilePath, 'doneFilePath');
|
|
226
|
+
const backlogItemsDir = overrides?.backlogItemsDir ?? path$1.join(lumpPath, 'backlogItems');
|
|
227
|
+
assertProjectRelativePath(backlogItemsDir, 'backlogItemsDir');
|
|
230
228
|
return {
|
|
231
229
|
lumpPath,
|
|
232
230
|
lumpName,
|
|
233
|
-
|
|
234
|
-
doneFilePath,
|
|
231
|
+
backlogItemsDir,
|
|
235
232
|
};
|
|
236
233
|
}
|
|
237
234
|
|
|
235
|
+
const CONTEXT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
236
|
+
function assertRecord(value, location) {
|
|
237
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
238
|
+
throw new Error(`Backlog item ${location} must be an object`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function assertStringField(record, field, location) {
|
|
242
|
+
const value = record[field];
|
|
243
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
244
|
+
throw new Error(`Backlog item ${location} requires non-empty string field "${field}"`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function assertNumberField(record, field, location) {
|
|
248
|
+
const value = record[field];
|
|
249
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
250
|
+
throw new Error(`Backlog item ${location} requires numeric field "${field}"`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Validates and normalizes one backlog item; throws on invalid input. */
|
|
254
|
+
function validateBaseBacklogItem(raw, location) {
|
|
255
|
+
assertRecord(raw, location);
|
|
256
|
+
assertStringField(raw, 'name', location);
|
|
257
|
+
assertStringField(raw, 'task', location);
|
|
258
|
+
assertNumberField(raw, 'priority', location);
|
|
259
|
+
if (!CONTEXT_NAME_PATTERN.test(raw.name)) {
|
|
260
|
+
throw new Error(`Backlog item ${location} has invalid name "${raw.name}" (expected ^[a-zA-Z0-9_-]+$)`);
|
|
261
|
+
}
|
|
262
|
+
let dependsOn;
|
|
263
|
+
if (raw.dependsOn !== undefined) {
|
|
264
|
+
if (!Array.isArray(raw.dependsOn)) {
|
|
265
|
+
throw new Error(`Backlog item ${location} field "dependsOn" must be an array`);
|
|
266
|
+
}
|
|
267
|
+
for (const dep of raw.dependsOn) {
|
|
268
|
+
if (typeof dep !== 'string' || dep.trim() === '') {
|
|
269
|
+
throw new Error(`Backlog item ${location} field "dependsOn" must contain non-empty strings`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
dependsOn = raw.dependsOn;
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
name: raw.name,
|
|
276
|
+
task: raw.task,
|
|
277
|
+
priority: raw.priority,
|
|
278
|
+
dependsOn,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function listTodoFolderNames(todoDir) {
|
|
283
|
+
try {
|
|
284
|
+
const entries = await fs.readdir(todoDir, { withFileTypes: true });
|
|
285
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const err = error;
|
|
289
|
+
if (err.code === 'ENOENT') {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
296
|
+
return async () => {
|
|
297
|
+
const todoDir = path$1.join(backlogItemsDir, 'todo');
|
|
298
|
+
const folderNames = await listTodoFolderNames(todoDir);
|
|
299
|
+
const discovered = await Promise.all(folderNames.map(async (folderName) => {
|
|
300
|
+
const descPath = path$1.join(todoDir, folderName, 'desc.yml');
|
|
301
|
+
let rawText;
|
|
302
|
+
try {
|
|
303
|
+
rawText = await fs.readFile(descPath, 'utf-8');
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
const err = error;
|
|
307
|
+
if (err.code === 'ENOENT') {
|
|
308
|
+
throw new Error(`Backlog item folder "${folderName}" is missing desc.yml at ${descPath}`);
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
const raw = load(rawText);
|
|
313
|
+
const baseItem = validateBaseBacklogItem(raw, `in folder "${folderName}"`);
|
|
314
|
+
if (baseItem.name !== folderName) {
|
|
315
|
+
throw new Error(`Backlog item folder "${folderName}" desc.yml name "${baseItem.name}" must match folder name`);
|
|
316
|
+
}
|
|
317
|
+
const item = parseItem ? parseItem(baseItem, folderName, raw) : baseItem;
|
|
318
|
+
return { item, folderName };
|
|
319
|
+
}));
|
|
320
|
+
discovered.sort((a, b) => {
|
|
321
|
+
if (a.item.priority !== b.item.priority) {
|
|
322
|
+
return a.item.priority - b.item.priority;
|
|
323
|
+
}
|
|
324
|
+
return a.item.name.localeCompare(b.item.name);
|
|
325
|
+
});
|
|
326
|
+
const allCtxs = await Promise.all(discovered.map(async ({ item, folderName }) => {
|
|
327
|
+
const { parsed, ignored } = parseContext
|
|
328
|
+
? await parseContext(item, folderName)
|
|
329
|
+
: { parsed: undefined, ignored: false };
|
|
330
|
+
if (ignored) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
name: item.name,
|
|
335
|
+
options: {
|
|
336
|
+
priority: item.priority,
|
|
337
|
+
dependsOnContexts: item.dependsOn,
|
|
338
|
+
},
|
|
339
|
+
variables: parsed?.variables ?? {},
|
|
340
|
+
...parsed,
|
|
341
|
+
};
|
|
342
|
+
}));
|
|
343
|
+
return allCtxs.filter((ctx) => !!ctx);
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isPlainObject(value) {
|
|
348
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
349
|
+
}
|
|
350
|
+
/** Moves a finished backlog item folder from todo/ to completed/ after the context completes. */
|
|
351
|
+
const folderSetTaskDoneStep = (input) => {
|
|
352
|
+
const nameVarName = input.nameVarName ?? 'TASK_NAME';
|
|
353
|
+
return {
|
|
354
|
+
async commandFn({ context, workspacePath }) {
|
|
355
|
+
const variables = context.variables;
|
|
356
|
+
const itemsDirRelative = variables[input.itemsDirVarName];
|
|
357
|
+
const taskName = variables[nameVarName];
|
|
358
|
+
if (!itemsDirRelative || !taskName) {
|
|
359
|
+
throw new Error('Backlog items directory and task name are required');
|
|
360
|
+
}
|
|
361
|
+
const itemsDir = path$1.join(workspacePath, itemsDirRelative);
|
|
362
|
+
const fromDir = path$1.join(itemsDir, 'todo', taskName);
|
|
363
|
+
const toDir = path$1.join(itemsDir, 'completed', taskName);
|
|
364
|
+
const descPath = path$1.join(fromDir, 'desc.yml');
|
|
365
|
+
const completedDescPath = path$1.join(toDir, 'desc.yml');
|
|
366
|
+
if (!(await pathExists(fromDir))) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
if (await pathExists(toDir)) {
|
|
370
|
+
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName}": already exists at ${toDir}`);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const rawText = await fs.readFile(descPath, 'utf-8');
|
|
374
|
+
const raw = load(rawText);
|
|
375
|
+
if (!isPlainObject(raw)) {
|
|
376
|
+
throw new Error(`Backlog desc.yml at ${descPath} must contain a YAML object`);
|
|
377
|
+
}
|
|
378
|
+
const updated = {
|
|
379
|
+
...raw,
|
|
380
|
+
completedAt: new Date().toISOString(),
|
|
381
|
+
};
|
|
382
|
+
await fs.mkdir(path$1.join(itemsDir, 'completed'), { recursive: true });
|
|
383
|
+
await fs.rename(fromDir, toDir);
|
|
384
|
+
await fs.writeFile(completedDescPath, dump(updated));
|
|
385
|
+
return {
|
|
386
|
+
executable: 'cat',
|
|
387
|
+
args: [completedDescPath],
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
continueOnError: true,
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
let setTaskDoneDeprecatedWarned = false;
|
|
395
|
+
function warnSetTaskDoneDeprecated() {
|
|
396
|
+
if (setTaskDoneDeprecatedWarned) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
setTaskDoneDeprecatedWarned = true;
|
|
400
|
+
console.warn('[lumpcode/recipes] setTaskDoneStep is deprecated; use folderSetTaskDoneStep. ' +
|
|
401
|
+
'YAML backlog helpers will be removed in a future major version.');
|
|
402
|
+
}
|
|
238
403
|
/** Moves a finished backlog item from BACKLOG.yml to DONE.yml after the context completes. */
|
|
239
404
|
const setTaskDoneStep = (input) => {
|
|
240
405
|
return {
|
|
241
406
|
async commandFn({ context, workspacePath }) {
|
|
407
|
+
warnSetTaskDoneDeprecated();
|
|
242
408
|
const variables = context.variables;
|
|
243
409
|
const { backlogVarName, doneVarName } = input;
|
|
244
410
|
const baseBacklogFilePath = variables[backlogVarName];
|
|
@@ -276,62 +442,25 @@ function resolveImplValidateCommand(implValidateCommand) {
|
|
|
276
442
|
return (_input) => implValidateCommand;
|
|
277
443
|
}
|
|
278
444
|
|
|
279
|
-
|
|
280
|
-
function
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
function assertStringField(record, field, index) {
|
|
286
|
-
const value = record[field];
|
|
287
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
288
|
-
throw new Error(`Backlog item at index ${index} requires non-empty string field "${field}"`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
function assertNumberField(record, field, index) {
|
|
292
|
-
const value = record[field];
|
|
293
|
-
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
294
|
-
throw new Error(`Backlog item at index ${index} requires numeric field "${field}"`);
|
|
445
|
+
let ymlBacklogDeprecatedWarned = false;
|
|
446
|
+
function warnYmlBacklogDeprecated() {
|
|
447
|
+
if (ymlBacklogDeprecatedWarned) {
|
|
448
|
+
return;
|
|
295
449
|
}
|
|
450
|
+
ymlBacklogDeprecatedWarned = true;
|
|
451
|
+
console.warn('[lumpcode/recipes] ymlBacklogContexts is deprecated; use folderBacklogContexts. ' +
|
|
452
|
+
'YAML backlog helpers will be removed in a future major version.');
|
|
296
453
|
}
|
|
297
|
-
/** Validates and normalizes one YAML backlog row; throws on invalid input. */
|
|
298
|
-
function validateBaseBacklogItem(raw, index) {
|
|
299
|
-
assertRecord(raw, index);
|
|
300
|
-
assertStringField(raw, 'name', index);
|
|
301
|
-
assertStringField(raw, 'task', index);
|
|
302
|
-
assertNumberField(raw, 'priority', index);
|
|
303
|
-
if (!CONTEXT_NAME_PATTERN.test(raw.name)) {
|
|
304
|
-
throw new Error(`Backlog item at index ${index} has invalid name "${raw.name}" (expected ^[a-zA-Z0-9_-]+$)`);
|
|
305
|
-
}
|
|
306
|
-
let dependsOn;
|
|
307
|
-
if (raw.dependsOn !== undefined) {
|
|
308
|
-
if (!Array.isArray(raw.dependsOn)) {
|
|
309
|
-
throw new Error(`Backlog item at index ${index} field "dependsOn" must be an array`);
|
|
310
|
-
}
|
|
311
|
-
for (const dep of raw.dependsOn) {
|
|
312
|
-
if (typeof dep !== 'string' || dep.trim() === '') {
|
|
313
|
-
throw new Error(`Backlog item at index ${index} field "dependsOn" must contain non-empty strings`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
dependsOn = raw.dependsOn;
|
|
317
|
-
}
|
|
318
|
-
return {
|
|
319
|
-
name: raw.name,
|
|
320
|
-
task: raw.task,
|
|
321
|
-
priority: raw.priority,
|
|
322
|
-
dependsOn,
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
|
|
326
454
|
function ymlBacklogContexts({ backlogFilePath, parseItem, parseContext, }) {
|
|
327
455
|
return async () => {
|
|
456
|
+
warnYmlBacklogDeprecated();
|
|
328
457
|
const raw = await fs.readFile(backlogFilePath, 'utf-8');
|
|
329
458
|
const doc = load(raw);
|
|
330
459
|
if (!Array.isArray(doc)) {
|
|
331
460
|
throw new Error(`Backlog file ${backlogFilePath} must contain a YAML list`);
|
|
332
461
|
}
|
|
333
462
|
const allCtxs = await Promise.all(doc.map(async (rawItem, index) => {
|
|
334
|
-
const baseItem = validateBaseBacklogItem(rawItem, index);
|
|
463
|
+
const baseItem = validateBaseBacklogItem(rawItem, `at index ${index}`);
|
|
335
464
|
const item = parseItem ? parseItem(baseItem, index, rawItem) : baseItem;
|
|
336
465
|
const { parsed, ignored } = parseContext
|
|
337
466
|
? await parseContext(item, index)
|
|
@@ -359,8 +488,8 @@ function defineRecipe(recipe) {
|
|
|
359
488
|
const BACKLOG_STAGE_VAR = 'BACKLOG_STAGE';
|
|
360
489
|
const BACKLOG_TASK_NAME_VAR = 'TASK_NAME';
|
|
361
490
|
const BACKLOG_TASK_VAR = 'TASK';
|
|
362
|
-
const
|
|
363
|
-
const
|
|
491
|
+
const BACKLOG_ITEMS_DIR_VAR = 'BACKLOG_ITEMS_DIR';
|
|
492
|
+
const BACKLOG_ITEM_DIR_VAR = 'BACKLOG_ITEM_DIR';
|
|
364
493
|
|
|
365
494
|
function isIgnoredResolution(resolution) {
|
|
366
495
|
return 'ignored' in resolution && resolution.ignored === true;
|
|
@@ -377,27 +506,25 @@ function buildStageSteps(stages, stageName) {
|
|
|
377
506
|
if (stageDef.completion === 'moveToDone') {
|
|
378
507
|
return [
|
|
379
508
|
...normalized,
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
doneVarName: BACKLOG_DONE_FILE_VAR,
|
|
509
|
+
folderSetTaskDoneStep({
|
|
510
|
+
itemsDirVarName: BACKLOG_ITEMS_DIR_VAR,
|
|
383
511
|
}),
|
|
384
512
|
];
|
|
385
513
|
}
|
|
386
514
|
return normalized;
|
|
387
515
|
}
|
|
388
516
|
function backlog(options) {
|
|
389
|
-
const { configUrl,
|
|
517
|
+
const { configUrl, backlogItemsDir: backlogItemsDirOverride, stages, parseItem, resolveItem, ...rest } = options;
|
|
390
518
|
const paths = resolveBacklogPaths(configUrl, {
|
|
391
|
-
|
|
392
|
-
doneFilePath: doneFilePathOverride,
|
|
519
|
+
backlogItemsDir: backlogItemsDirOverride,
|
|
393
520
|
});
|
|
394
521
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
395
|
-
const
|
|
522
|
+
const absoluteBacklogItemsDir = path$1.join(projectRoot, paths.backlogItemsDir);
|
|
396
523
|
return defineConfig({
|
|
397
|
-
getContextListFn:
|
|
398
|
-
|
|
524
|
+
getContextListFn: folderBacklogContexts({
|
|
525
|
+
backlogItemsDir: absoluteBacklogItemsDir,
|
|
399
526
|
parseItem,
|
|
400
|
-
async parseContext(item) {
|
|
527
|
+
async parseContext(item, folderName) {
|
|
401
528
|
const resolution = await resolveItem({ item, paths });
|
|
402
529
|
if (isIgnoredResolution(resolution)) {
|
|
403
530
|
return { ignored: true };
|
|
@@ -407,14 +534,15 @@ function backlog(options) {
|
|
|
407
534
|
...(item.dependsOn ?? []),
|
|
408
535
|
...(additionalDependsOnContexts ?? []),
|
|
409
536
|
];
|
|
537
|
+
const backlogItemDir = path$1.join(paths.backlogItemsDir, 'todo', folderName);
|
|
410
538
|
return {
|
|
411
539
|
parsed: {
|
|
412
540
|
name: contextName ?? item.name,
|
|
413
541
|
variables: {
|
|
414
542
|
[BACKLOG_TASK_NAME_VAR]: item.name,
|
|
415
543
|
[BACKLOG_TASK_VAR]: item.task,
|
|
416
|
-
[
|
|
417
|
-
[
|
|
544
|
+
[BACKLOG_ITEMS_DIR_VAR]: paths.backlogItemsDir,
|
|
545
|
+
[BACKLOG_ITEM_DIR_VAR]: backlogItemDir,
|
|
418
546
|
[BACKLOG_STAGE_VAR]: stage,
|
|
419
547
|
...variables,
|
|
420
548
|
},
|
|
@@ -441,18 +569,14 @@ function backlog(options) {
|
|
|
441
569
|
}
|
|
442
570
|
const backlogRecipe = defineRecipe((options) => backlog(options));
|
|
443
571
|
|
|
444
|
-
const DEFAULT_IMPL_VALIDATE_COMMAND = [
|
|
445
|
-
'npm run build -w=@lumpcode/cli',
|
|
446
|
-
'npm run test -w=@lumpcode/cli',
|
|
447
|
-
].join(' && ');
|
|
448
572
|
const abstractionBacklog = defineRecipe((options) => {
|
|
449
|
-
const { implValidateCommand
|
|
573
|
+
const { implValidateCommand, configUrl, implSteps, ...rest } = options;
|
|
450
574
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
451
|
-
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
575
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand ?? 'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
452
576
|
return backlog({
|
|
453
577
|
configUrl,
|
|
454
578
|
async resolveItem({ item, paths }) {
|
|
455
|
-
const itemPrdPath = path$1.join(paths.
|
|
579
|
+
const itemPrdPath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'prd.md');
|
|
456
580
|
const hasPrd = await pathExists(path$1.join(projectRoot, itemPrdPath));
|
|
457
581
|
if (!hasPrd) {
|
|
458
582
|
return { ignored: true };
|
|
@@ -468,7 +592,7 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
468
592
|
implementation: {
|
|
469
593
|
completion: 'moveToDone',
|
|
470
594
|
steps: retryUntilGreen({
|
|
471
|
-
steps: [{
|
|
595
|
+
steps: implSteps ?? [{
|
|
472
596
|
promptFn({ context: ctx }) {
|
|
473
597
|
const vars = ctx.variables;
|
|
474
598
|
const { PRD_FILE, TASK_NAME, TASK } = vars;
|
|
@@ -480,10 +604,10 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
480
604
|
${TASK}
|
|
481
605
|
|
|
482
606
|
Requirements:
|
|
483
|
-
- Materialize the abstraction as a new util
|
|
484
|
-
- Refactor all call sites
|
|
607
|
+
- Materialize the abstraction as a new util following existing conventions in the codebase.
|
|
608
|
+
- Refactor all call sites to import the new util.
|
|
485
609
|
- 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.
|
|
486
|
-
- Include unit tests
|
|
610
|
+
- Include unit tests for the new util.
|
|
487
611
|
`.trim();
|
|
488
612
|
},
|
|
489
613
|
}],
|
|
@@ -496,43 +620,44 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
496
620
|
});
|
|
497
621
|
|
|
498
622
|
const abstractionFinder = defineRecipe((options) => {
|
|
499
|
-
const { maxPendingAbstractions = 5, scanDirectories, customPrompt,
|
|
623
|
+
const { maxPendingAbstractions = 5, scanDirectories, customPrompt, backlogItemsDir, } = options; // TODO : check if pending abstcations in backlog are less than maxPendingAbstractions
|
|
500
624
|
return defineConfig({
|
|
501
625
|
...options,
|
|
502
626
|
getContextListFn: ephemeralContextListFn({
|
|
503
627
|
contextCount: maxPendingAbstractions,
|
|
504
628
|
variables: {
|
|
505
|
-
|
|
506
|
-
BACKLOG_FILE_PATH: backlogFilePath ?? '',
|
|
507
|
-
DONE_FILE_PATH: doneFilePath ?? '',
|
|
629
|
+
BACKLOG_ITEMS_DIR: backlogItemsDir,
|
|
508
630
|
},
|
|
509
631
|
}),
|
|
510
|
-
steps: buildFinderPrompt({
|
|
632
|
+
steps: buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt }),
|
|
511
633
|
});
|
|
512
634
|
});
|
|
513
|
-
function buildFinderPrompt({
|
|
635
|
+
function buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt, }) {
|
|
514
636
|
return customPrompt ? customPrompt() : `
|
|
515
637
|
Scan ${(scanDirectories || []).map((dir) => `@${dir}`).join(' and ') || 'the codebase'} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
|
|
516
638
|
|
|
517
|
-
|
|
639
|
+
List existing backlog item names under @${backlogItemsDir}/todo/ and @${backlogItemsDir}/completed/. Do not propose abstractions whose util name already appears in either directory.
|
|
518
640
|
|
|
519
641
|
Pick exactly one new abstraction that:
|
|
520
642
|
- Has a clear util name matching ^[a-zA-Z0-9_-]+$ that describes the pattern it captures.
|
|
521
643
|
- Would materialize as a new util under packages/apps/cli/src/utils/<utilName>/.
|
|
522
644
|
- Would shrink the codebase: refactoring all call sites in packages/apps/cli should reduce net line count (excluding new unit tests).
|
|
523
645
|
|
|
524
|
-
|
|
525
|
-
-
|
|
526
|
-
-
|
|
527
|
-
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
-
|
|
531
|
-
-
|
|
532
|
-
-
|
|
533
|
-
-
|
|
534
|
-
|
|
535
|
-
|
|
646
|
+
Create exactly one new backlog item folder at @${backlogItemsDir}/todo/<utilName>/ with:
|
|
647
|
+
- desc.yml containing:
|
|
648
|
+
- name: <utilName> (must match folder name)
|
|
649
|
+
- task: a concise summary of the repeated pattern, proposed util name, and affected areas
|
|
650
|
+
- priority: max existing priority in todo/ plus 1 (or 1 if todo/ is empty)
|
|
651
|
+
- dependsOn: optional list of util names from todo/ or completed/ that must land first (only when clearly needed)
|
|
652
|
+
- prd.md: an implementation-ready PRD for the same util name. The PRD should be self-contained and include:
|
|
653
|
+
- Problem statement and repeated pattern
|
|
654
|
+
- Goals and non-goals
|
|
655
|
+
- Proposed util API and affected files
|
|
656
|
+
- Acceptance criteria (including net line reduction and unit tests)
|
|
657
|
+
|
|
658
|
+
Do not implement code. Only create @${backlogItemsDir}/todo/<utilName>/desc.yml and @${backlogItemsDir}/todo/<utilName>/prd.md.
|
|
659
|
+
|
|
660
|
+
Do not take too much time looking for every possible abstraction. Once you found a good abstraction, stop and create the backlog item.
|
|
536
661
|
`.trim();
|
|
537
662
|
}
|
|
538
663
|
|
|
@@ -561,8 +686,8 @@ function featureContextName(itemName, stage) {
|
|
|
561
686
|
}
|
|
562
687
|
}
|
|
563
688
|
async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, baseBranch) {
|
|
564
|
-
const prdFilePath = path$1.join(paths.
|
|
565
|
-
const testPlanFilePath = path$1.join(paths.
|
|
689
|
+
const prdFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'prd.md');
|
|
690
|
+
const testPlanFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'testPlan.md');
|
|
566
691
|
const hasPrd = await pathExists(path$1.join(projectRoot, prdFilePath));
|
|
567
692
|
if (!hasPrd) {
|
|
568
693
|
if (item.manualPrd === true) {
|
|
@@ -615,15 +740,14 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
615
740
|
};
|
|
616
741
|
}
|
|
617
742
|
const featureBacklog = defineRecipe((options) => {
|
|
618
|
-
const { configUrl, baseBranch, implValidateCommand,
|
|
743
|
+
const { configUrl, baseBranch, implValidateCommand, backlogItemsDir, ...rest } = options;
|
|
619
744
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
620
|
-
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
745
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand ?? 'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
621
746
|
return backlog({
|
|
622
747
|
configUrl,
|
|
623
|
-
|
|
624
|
-
doneFilePath,
|
|
748
|
+
backlogItemsDir,
|
|
625
749
|
baseBranch,
|
|
626
|
-
parseItem(baseItem,
|
|
750
|
+
parseItem(baseItem, _folderName, raw) {
|
|
627
751
|
assertValidFeatureItemName(baseItem.name);
|
|
628
752
|
const record = raw;
|
|
629
753
|
if (record.manualPrd !== undefined && typeof record.manualPrd !== 'boolean') {
|
|
@@ -644,16 +768,16 @@ const featureBacklog = defineRecipe((options) => {
|
|
|
644
768
|
{
|
|
645
769
|
promptFn({ context: ctx }) {
|
|
646
770
|
const vars = ctx.variables;
|
|
647
|
-
const {
|
|
771
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, PRD_FILE } = vars;
|
|
648
772
|
return `
|
|
649
|
-
Write a product requirements document (PRD) for the following
|
|
773
|
+
Write a product requirements document (PRD) for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
650
774
|
|
|
651
775
|
Task name: ${TASK_NAME}
|
|
652
776
|
|
|
653
777
|
Task:
|
|
654
778
|
${TASK}
|
|
655
779
|
|
|
656
|
-
Save the PRD to @${PRD_FILE}. Do not edit @${
|
|
780
|
+
Save the PRD to @${PRD_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
657
781
|
|
|
658
782
|
The PRD should be self-contained and implementation-ready. Include:
|
|
659
783
|
- Problem statement and motivation
|
|
@@ -678,9 +802,9 @@ The PRD should not contain any testing strategy details.
|
|
|
678
802
|
{
|
|
679
803
|
promptFn({ context: ctx }) {
|
|
680
804
|
const vars = ctx.variables;
|
|
681
|
-
const {
|
|
805
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, PRD_FILE, TEST_PLAN_FILE } = vars;
|
|
682
806
|
return `
|
|
683
|
-
Write a test plan for the following
|
|
807
|
+
Write a test plan for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
684
808
|
|
|
685
809
|
Task name: ${TASK_NAME}
|
|
686
810
|
Task:
|
|
@@ -688,7 +812,7 @@ ${TASK}
|
|
|
688
812
|
|
|
689
813
|
The PRD for this task is @${PRD_FILE}. The test plan should match the requirements of the PRD.
|
|
690
814
|
|
|
691
|
-
Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${
|
|
815
|
+
Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${PRD_FILE}.
|
|
692
816
|
|
|
693
817
|
The test plan should be self-contained and implementation-ready. Include:
|
|
694
818
|
- Test cases
|
|
@@ -707,9 +831,9 @@ The test plan should be self-contained and implementation-ready. Include:
|
|
|
707
831
|
{
|
|
708
832
|
promptFn({ context: ctx }) {
|
|
709
833
|
const vars = ctx.variables;
|
|
710
|
-
const {
|
|
834
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, PRD_FILE, TEST_PLAN_FILE } = vars;
|
|
711
835
|
return `
|
|
712
|
-
Write a test implementation for the following
|
|
836
|
+
Write a test implementation for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
713
837
|
|
|
714
838
|
Task name: ${TASK_NAME}
|
|
715
839
|
Task:
|
|
@@ -746,4 +870,4 @@ The implementation should make the tests pass. Do not edit any test file.
|
|
|
746
870
|
});
|
|
747
871
|
});
|
|
748
872
|
|
|
749
|
-
export {
|
|
873
|
+
export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, lumpPathAndName, normalizeMaybePromGetter, projectRootFromConfigUrl, requireArtifactStep, resolveBacklogPaths, resolveFeatureBacklogItem, resolveImplValidateCommand, retryUntilGreen, setTaskDoneStep, shellCommand, validateBaseBacklogItem, ymlBacklogContexts };
|