@lumpcode/recipes 0.0.13 → 0.0.15
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 +35 -12
- package/dist/index.cjs +263 -133
- package/dist/index.d.ts +47 -43
- package/dist/index.js +261 -133
- package/package.json +1 -1
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
|
},
|
|
@@ -446,19 +574,19 @@ const backlogRecipe = defineRecipe((options) => backlog(options));
|
|
|
446
574
|
const abstractionBacklog = defineRecipe((options) => {
|
|
447
575
|
const { implValidateCommand, configUrl, implSteps, ...rest } = options;
|
|
448
576
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
449
|
-
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
577
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand ?? 'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
450
578
|
return backlog({
|
|
451
579
|
configUrl,
|
|
452
580
|
async resolveItem({ item, paths }) {
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
if (!
|
|
581
|
+
const itemReqPath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'requirements.md');
|
|
582
|
+
const hasReq = await core.pathExists(path$1.join(projectRoot, itemReqPath));
|
|
583
|
+
if (!hasReq) {
|
|
456
584
|
return { ignored: true };
|
|
457
585
|
}
|
|
458
586
|
return {
|
|
459
587
|
stage: 'implementation',
|
|
460
588
|
variables: {
|
|
461
|
-
|
|
589
|
+
REQ_FILE: itemReqPath,
|
|
462
590
|
},
|
|
463
591
|
};
|
|
464
592
|
},
|
|
@@ -469,9 +597,9 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
469
597
|
steps: implSteps ?? [{
|
|
470
598
|
promptFn({ context: ctx }) {
|
|
471
599
|
const vars = ctx.variables;
|
|
472
|
-
const {
|
|
600
|
+
const { REQ_FILE, TASK_NAME, TASK } = vars;
|
|
473
601
|
return `
|
|
474
|
-
Implement the abstraction described in @${
|
|
602
|
+
Implement the abstraction described in @${REQ_FILE}.
|
|
475
603
|
|
|
476
604
|
Backlog item: ${TASK_NAME}
|
|
477
605
|
Task summary:
|
|
@@ -494,47 +622,48 @@ const abstractionBacklog = defineRecipe((options) => {
|
|
|
494
622
|
});
|
|
495
623
|
|
|
496
624
|
const abstractionFinder = defineRecipe((options) => {
|
|
497
|
-
const { maxPendingAbstractions = 5, scanDirectories, customPrompt,
|
|
625
|
+
const { maxPendingAbstractions = 5, scanDirectories, customPrompt, backlogItemsDir, } = options; // TODO : check if pending abstcations in backlog are less than maxPendingAbstractions
|
|
498
626
|
return cliUtils.defineConfig({
|
|
499
627
|
...options,
|
|
500
628
|
getContextListFn: ephemeralContextListFn({
|
|
501
629
|
contextCount: maxPendingAbstractions,
|
|
502
630
|
variables: {
|
|
503
|
-
|
|
504
|
-
BACKLOG_FILE_PATH: backlogFilePath ?? '',
|
|
505
|
-
DONE_FILE_PATH: doneFilePath ?? '',
|
|
631
|
+
BACKLOG_ITEMS_DIR: backlogItemsDir,
|
|
506
632
|
},
|
|
507
633
|
}),
|
|
508
|
-
steps: buildFinderPrompt({
|
|
634
|
+
steps: buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt }),
|
|
509
635
|
});
|
|
510
636
|
});
|
|
511
|
-
function buildFinderPrompt({
|
|
637
|
+
function buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt, }) {
|
|
512
638
|
return customPrompt ? customPrompt() : `
|
|
513
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).
|
|
514
640
|
|
|
515
|
-
|
|
641
|
+
List existing backlog item names under @${backlogItemsDir}/todo/ and @${backlogItemsDir}/completed/. Do not propose abstractions whose util name already appears in either directory.
|
|
516
642
|
|
|
517
643
|
Pick exactly one new abstraction that:
|
|
518
644
|
- Has a clear util name matching ^[a-zA-Z0-9_-]+$ that describes the pattern it captures.
|
|
519
645
|
- Would materialize as a new util under packages/apps/cli/src/utils/<utilName>/.
|
|
520
646
|
- Would shrink the codebase: refactoring all call sites in packages/apps/cli should reduce net line count (excluding new unit tests).
|
|
521
647
|
|
|
522
|
-
|
|
523
|
-
-
|
|
524
|
-
-
|
|
525
|
-
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
-
|
|
529
|
-
-
|
|
530
|
-
-
|
|
531
|
-
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
+
- requirements.md: an implementation-ready requirements document for the same util name. It 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>/requirements.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.
|
|
534
663
|
`.trim();
|
|
535
664
|
}
|
|
536
665
|
|
|
537
|
-
const RESERVED_NAME_SUFFIXES = ['
|
|
666
|
+
const RESERVED_NAME_SUFFIXES = ['_req', '_testPlan', '_tests_impl'];
|
|
538
667
|
function assertValidFeatureItemName(name) {
|
|
539
668
|
for (const suffix of RESERVED_NAME_SUFFIXES) {
|
|
540
669
|
if (name.endsWith(suffix)) {
|
|
@@ -544,8 +673,8 @@ function assertValidFeatureItemName(name) {
|
|
|
544
673
|
}
|
|
545
674
|
function featureContextName(itemName, stage) {
|
|
546
675
|
switch (stage) {
|
|
547
|
-
case '
|
|
548
|
-
return `${itemName}
|
|
676
|
+
case 'makeReq':
|
|
677
|
+
return `${itemName}_req`;
|
|
549
678
|
case 'makeTestPlan':
|
|
550
679
|
return `${itemName}_testPlan`;
|
|
551
680
|
case 'testImpl':
|
|
@@ -559,17 +688,17 @@ function featureContextName(itemName, stage) {
|
|
|
559
688
|
}
|
|
560
689
|
}
|
|
561
690
|
async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, baseBranch) {
|
|
562
|
-
const
|
|
563
|
-
const testPlanFilePath = path$1.join(paths.
|
|
564
|
-
const
|
|
565
|
-
if (!
|
|
566
|
-
if (item.
|
|
691
|
+
const reqFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'requirements.md');
|
|
692
|
+
const testPlanFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'testPlan.md');
|
|
693
|
+
const hasReq = await core.pathExists(path$1.join(projectRoot, reqFilePath));
|
|
694
|
+
if (!hasReq) {
|
|
695
|
+
if (item.manualReq === true) {
|
|
567
696
|
return { ignored: true };
|
|
568
697
|
}
|
|
569
698
|
return {
|
|
570
|
-
stage: '
|
|
571
|
-
contextName: featureContextName(item.name, '
|
|
572
|
-
variables: {
|
|
699
|
+
stage: 'makeReq',
|
|
700
|
+
contextName: featureContextName(item.name, 'makeReq'),
|
|
701
|
+
variables: { REQ_FILE: reqFilePath },
|
|
573
702
|
};
|
|
574
703
|
}
|
|
575
704
|
const hasTestPlan = await core.pathExists(path$1.join(projectRoot, testPlanFilePath));
|
|
@@ -578,7 +707,7 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
578
707
|
stage: 'makeTestPlan',
|
|
579
708
|
contextName: featureContextName(item.name, 'makeTestPlan'),
|
|
580
709
|
variables: {
|
|
581
|
-
|
|
710
|
+
REQ_FILE: reqFilePath,
|
|
582
711
|
TEST_PLAN_FILE: testPlanFilePath,
|
|
583
712
|
},
|
|
584
713
|
};
|
|
@@ -595,7 +724,7 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
595
724
|
stage: 'implementation',
|
|
596
725
|
contextName: featureContextName(item.name, 'implementation'),
|
|
597
726
|
variables: {
|
|
598
|
-
|
|
727
|
+
REQ_FILE: reqFilePath,
|
|
599
728
|
TEST_PLAN_FILE: testPlanFilePath,
|
|
600
729
|
},
|
|
601
730
|
};
|
|
@@ -607,53 +736,52 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
607
736
|
stage: 'testImpl',
|
|
608
737
|
contextName: testsImplContextName,
|
|
609
738
|
variables: {
|
|
610
|
-
|
|
739
|
+
REQ_FILE: reqFilePath,
|
|
611
740
|
TEST_PLAN_FILE: testPlanFilePath,
|
|
612
741
|
},
|
|
613
742
|
};
|
|
614
743
|
}
|
|
615
744
|
const featureBacklog = defineRecipe((options) => {
|
|
616
|
-
const { configUrl, baseBranch, implValidateCommand,
|
|
745
|
+
const { configUrl, baseBranch, implValidateCommand, backlogItemsDir, ...rest } = options;
|
|
617
746
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
618
|
-
const runImplValidation = resolveImplValidateCommand(implValidateCommand);
|
|
747
|
+
const runImplValidation = resolveImplValidateCommand(implValidateCommand ?? 'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
619
748
|
return backlog({
|
|
620
749
|
configUrl,
|
|
621
|
-
|
|
622
|
-
doneFilePath,
|
|
750
|
+
backlogItemsDir,
|
|
623
751
|
baseBranch,
|
|
624
|
-
parseItem(baseItem,
|
|
752
|
+
parseItem(baseItem, _folderName, raw) {
|
|
625
753
|
assertValidFeatureItemName(baseItem.name);
|
|
626
754
|
const record = raw;
|
|
627
|
-
if (record.
|
|
628
|
-
throw new Error(`Backlog item "${baseItem.name}" field "
|
|
755
|
+
if (record.manualReq !== undefined && typeof record.manualReq !== 'boolean') {
|
|
756
|
+
throw new Error(`Backlog item "${baseItem.name}" field "manualReq" must be a boolean`);
|
|
629
757
|
}
|
|
630
758
|
return {
|
|
631
759
|
...baseItem,
|
|
632
|
-
|
|
760
|
+
manualReq: record.manualReq === true ? true : undefined,
|
|
633
761
|
};
|
|
634
762
|
},
|
|
635
763
|
async resolveItem({ item, paths }) {
|
|
636
764
|
return resolveFeatureBacklogItem(item, paths, projectRoot, paths.lumpName, baseBranch);
|
|
637
765
|
},
|
|
638
766
|
stages: {
|
|
639
|
-
|
|
767
|
+
makeReq: {
|
|
640
768
|
completion: 'keepPending',
|
|
641
769
|
steps: [
|
|
642
770
|
{
|
|
643
771
|
promptFn({ context: ctx }) {
|
|
644
772
|
const vars = ctx.variables;
|
|
645
|
-
const {
|
|
773
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE } = vars;
|
|
646
774
|
return `
|
|
647
|
-
Write a
|
|
775
|
+
Write a requirements document for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
648
776
|
|
|
649
777
|
Task name: ${TASK_NAME}
|
|
650
778
|
|
|
651
779
|
Task:
|
|
652
780
|
${TASK}
|
|
653
781
|
|
|
654
|
-
Save the
|
|
782
|
+
Save the requirements document to @${REQ_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
655
783
|
|
|
656
|
-
The
|
|
784
|
+
The requirements document should be self-contained and implementation-ready. Include:
|
|
657
785
|
- Problem statement and motivation
|
|
658
786
|
- Goals and non-goals
|
|
659
787
|
- User stories / use cases
|
|
@@ -662,12 +790,12 @@ The PRD should be self-contained and implementation-ready. Include:
|
|
|
662
790
|
- Technical approach and affected packages or docs
|
|
663
791
|
- Acceptance criteria
|
|
664
792
|
|
|
665
|
-
Do not implement the feature — only create the
|
|
666
|
-
The
|
|
793
|
+
Do not implement the feature — only create the requirements markdown file.
|
|
794
|
+
The requirements document should not contain any testing strategy details.
|
|
667
795
|
`.trim();
|
|
668
796
|
},
|
|
669
797
|
},
|
|
670
|
-
requireArtifactStep('
|
|
798
|
+
requireArtifactStep('REQ_FILE'),
|
|
671
799
|
],
|
|
672
800
|
},
|
|
673
801
|
makeTestPlan: {
|
|
@@ -676,17 +804,17 @@ The PRD should not contain any testing strategy details.
|
|
|
676
804
|
{
|
|
677
805
|
promptFn({ context: ctx }) {
|
|
678
806
|
const vars = ctx.variables;
|
|
679
|
-
const {
|
|
807
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = vars;
|
|
680
808
|
return `
|
|
681
|
-
Write a test plan for the following
|
|
809
|
+
Write a test plan for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
682
810
|
|
|
683
811
|
Task name: ${TASK_NAME}
|
|
684
812
|
Task:
|
|
685
813
|
${TASK}
|
|
686
814
|
|
|
687
|
-
The
|
|
815
|
+
The requirements for this task are in @${REQ_FILE}. The test plan should match those requirements.
|
|
688
816
|
|
|
689
|
-
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 @${REQ_FILE}.
|
|
690
818
|
|
|
691
819
|
The test plan should be self-contained and implementation-ready. Include:
|
|
692
820
|
- Test cases
|
|
@@ -705,16 +833,16 @@ The test plan should be self-contained and implementation-ready. Include:
|
|
|
705
833
|
{
|
|
706
834
|
promptFn({ context: ctx }) {
|
|
707
835
|
const vars = ctx.variables;
|
|
708
|
-
const {
|
|
836
|
+
const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = vars;
|
|
709
837
|
return `
|
|
710
|
-
Write a test implementation for the following
|
|
838
|
+
Write a test implementation for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
|
|
711
839
|
|
|
712
840
|
Task name: ${TASK_NAME}
|
|
713
841
|
Task:
|
|
714
842
|
${TASK}
|
|
715
843
|
|
|
716
844
|
Follow the test plan in @${TEST_PLAN_FILE}.
|
|
717
|
-
The
|
|
845
|
+
The requirements for this task are in @${REQ_FILE}.
|
|
718
846
|
`.trim();
|
|
719
847
|
},
|
|
720
848
|
},
|
|
@@ -727,9 +855,9 @@ The PRD for this task is @${PRD_FILE}.
|
|
|
727
855
|
{
|
|
728
856
|
promptFn({ context: ctx }) {
|
|
729
857
|
const vars = ctx.variables;
|
|
730
|
-
const {
|
|
858
|
+
const { REQ_FILE, TEST_PLAN_FILE } = vars;
|
|
731
859
|
return `
|
|
732
|
-
Implement the feature described in @${
|
|
860
|
+
Implement the feature described in @${REQ_FILE}.
|
|
733
861
|
The tests have already been implemented according to the test plan in @${TEST_PLAN_FILE}.
|
|
734
862
|
The implementation should make the tests pass. Do not edit any test file.
|
|
735
863
|
`.trim();
|
|
@@ -744,8 +872,8 @@ The implementation should make the tests pass. Do not edit any test file.
|
|
|
744
872
|
});
|
|
745
873
|
});
|
|
746
874
|
|
|
747
|
-
exports.
|
|
748
|
-
exports.
|
|
875
|
+
exports.BACKLOG_ITEMS_DIR_VAR = BACKLOG_ITEMS_DIR_VAR;
|
|
876
|
+
exports.BACKLOG_ITEM_DIR_VAR = BACKLOG_ITEM_DIR_VAR;
|
|
749
877
|
exports.BACKLOG_STAGE_VAR = BACKLOG_STAGE_VAR;
|
|
750
878
|
exports.BACKLOG_TASK_NAME_VAR = BACKLOG_TASK_NAME_VAR;
|
|
751
879
|
exports.BACKLOG_TASK_VAR = BACKLOG_TASK_VAR;
|
|
@@ -756,6 +884,8 @@ exports.backlogRecipe = backlogRecipe;
|
|
|
756
884
|
exports.defineRecipe = defineRecipe;
|
|
757
885
|
exports.ephemeralContextListFn = ephemeralContextListFn;
|
|
758
886
|
exports.featureBacklog = featureBacklog;
|
|
887
|
+
exports.folderBacklogContexts = folderBacklogContexts;
|
|
888
|
+
exports.folderSetTaskDoneStep = folderSetTaskDoneStep;
|
|
759
889
|
exports.getRecursiveSteps = getRecursiveSteps;
|
|
760
890
|
exports.lumpPathAndName = lumpPathAndName;
|
|
761
891
|
exports.normalizeMaybePromGetter = normalizeMaybePromGetter;
|