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