@lumpcode/recipes 0.2.0 → 0.3.1
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 -9
- package/dist/index.cjs +341 -31
- package/dist/index.d.ts +48 -9
- package/dist/index.js +339 -34
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,8 +15,8 @@ npm install @lumpcode/recipes
|
|
|
15
15
|
| Recipe | Export | Use when |
|
|
16
16
|
|--------|--------|----------|
|
|
17
17
|
| **backlog** | `backlog` | Generic folder backlog with a typed stage map and per-item stage resolution |
|
|
18
|
-
| **featureBacklog** | `featureBacklog` |
|
|
19
|
-
| **abstractionFinder** | `abstractionFinder` |
|
|
18
|
+
| **featureBacklog** | `featureBacklog` | Folder feature campaign: TDD stages, optional `directImpl`, tickets, and `dev` / `feature/*` discovery |
|
|
19
|
+
| **abstractionFinder** | `abstractionFinder` | One ephemeral context per tick that appends a backlog item + requirements doc while `todo/` is under `maxPendingAbstractions` |
|
|
20
20
|
| **abstractionBacklog** | `abstractionBacklog` | Folder backlog items with requirements — implement abstraction with verify-until-green, then move item to completed/ |
|
|
21
21
|
|
|
22
22
|
Recipe factories and variable-carrying kit helpers accept the same dual generics as `defineConfig` from `@lumpcode/cli-utils`: `<V extends LumpVariables, SV extends StepVariables>`, with defaults equal to the unbound bags. Pass explicit type args when refining preset contracts; omit them for classic untyped configs.
|
|
@@ -29,13 +29,14 @@ The backlog recipes (`backlog`, `featureBacklog`, `abstractionBacklog`) use a fo
|
|
|
29
29
|
.lumpcode/lumps/<lump>/backlogItems/
|
|
30
30
|
todo/<name>/desc.yml
|
|
31
31
|
todo/<name>/requirements.md # optional until makeReq / finder writes it
|
|
32
|
-
todo/<name>/testPlan.md # featureBacklog
|
|
32
|
+
todo/<name>/testPlan.md # featureBacklog TDD; optional until makeTestPlan
|
|
33
|
+
todo/<parent>/tickets/<ticket>/desc.yml # featureBacklog: parent skipped when tickets/ is non-empty
|
|
33
34
|
completed/<name>/desc.yml # includes completedAt after move-to-done
|
|
34
35
|
completed/<name>/requirements.md # moves with the folder
|
|
35
36
|
completed/<name>/testPlan.md
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
`desc.yml` is a single YAML object with `name`, `task`, `priority`, optional `dependsOn`, and recipe-specific fields (
|
|
39
|
+
`desc.yml` is a single YAML object with `name`, `task`, `priority`, optional `dependsOn`, and recipe-specific fields (`manualReq`, `workflow` for featureBacklog).
|
|
39
40
|
|
|
40
41
|
## Kit
|
|
41
42
|
|
|
@@ -45,11 +46,21 @@ Flat helpers under `src/kit/` (re-exported from the package root):
|
|
|
45
46
|
- `getRecursiveSteps` — agent step(s) + validation command, retry until pass (retries via `postCommandExecFn` returned steps)
|
|
46
47
|
- `retryUntilGreen` — opinionated wrapper over `getRecursiveSteps` with default fix prompt
|
|
47
48
|
- `ephemeralContextListFn` — N fresh synthetic contexts per run (`contextCount`, index-aware names)
|
|
48
|
-
- `folderBacklogContexts` — `getContextListFn` from `backlogItems/todo/` with optional per-item parsing
|
|
49
|
+
- `folderBacklogContexts` — `getContextListFn` from `backlogItems/todo/` with optional per-item parsing (`listTodoRelativeDirs` lists todo-relative item folders, including tickets)
|
|
49
50
|
- `folderSetTaskDoneStep` — move finished item folder from `todo/` to `completed/` after a context completes
|
|
50
51
|
- `ymlBacklogContexts` / `setTaskDoneStep` — **deprecated** YAML-list helpers (warn once, still work)
|
|
51
52
|
- `resolveImplValidateCommand` — string, descriptor, or fn → `ValidationCommandFn`
|
|
52
53
|
- `shellCommand` — `sh -c` helper for validation commands
|
|
54
|
+
- `openPrPostTeardown` — `postTeardownWorkspaceFn` that opens a PR from `branchName` into resolved `baseBranch`. Pass `{ provider: 'github' }` (`gh` on PATH). Skips when the branch was not pushed or a PR already exists; create failures are logged and do not fail the run.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { featureBacklog, openPrPostTeardown } from '@lumpcode/recipes';
|
|
58
|
+
|
|
59
|
+
export default featureBacklog({
|
|
60
|
+
configUrl: import.meta.url,
|
|
61
|
+
postTeardownWorkspaceFn: openPrPostTeardown({ provider: 'github' }),
|
|
62
|
+
});
|
|
63
|
+
```
|
|
53
64
|
|
|
54
65
|
## Generic backlog stage map
|
|
55
66
|
|
|
@@ -92,15 +103,14 @@ export default featureBacklog<
|
|
|
92
103
|
CursorPresetLumpVariables,
|
|
93
104
|
CursorPresetStepVariables
|
|
94
105
|
>({
|
|
95
|
-
baseBranch: 'dev',
|
|
96
106
|
command: 'cursor',
|
|
97
107
|
configUrl: import.meta.url,
|
|
98
108
|
registerCommands: ['cursor'],
|
|
99
|
-
maximumNumberOfConcurrentBranches:
|
|
109
|
+
maximumNumberOfConcurrentBranches: 1,
|
|
100
110
|
verbose: true,
|
|
101
111
|
keepHistory: true,
|
|
102
112
|
lumpVariables: { model: 'composer-2.5' },
|
|
103
|
-
|
|
113
|
+
discoveryBranches: ['dev', 'feature/*'],
|
|
104
114
|
implValidateCommand: [
|
|
105
115
|
'npm run build -w=@lumpcode/cli',
|
|
106
116
|
'npm run test -w=@lumpcode/cli',
|
|
@@ -108,6 +118,8 @@ export default featureBacklog<
|
|
|
108
118
|
});
|
|
109
119
|
```
|
|
110
120
|
|
|
121
|
+
`desc.yml` `workflow`: omit ≡ `tdd` (`makeReq` → `makeTestPlan` → `testImpl` → `implementation`); `directImpl` skips the test-plan stages; `manual` is ignored. On `dev` only top-level `directImpl` items run (tickets never run on `dev`, even if `directImpl`); on `feature/<key>` the matching item (or parent, for tickets). Ticket context names are `<parent>-<ticket>`; `manualReq: true` waits for a human requirements file. Status reads use the concrete `discoveryBranch`.
|
|
122
|
+
|
|
111
123
|
### abstractionFinder + abstractionBacklog
|
|
112
124
|
|
|
113
125
|
Two-lump pipeline: finder tops up the implementer backlog; implementer runs items that already have requirements documents.
|
|
@@ -124,15 +136,19 @@ export default abstractionFinder<
|
|
|
124
136
|
CursorPresetLumpVariables,
|
|
125
137
|
CursorPresetStepVariables
|
|
126
138
|
>({
|
|
139
|
+
configUrl: import.meta.url,
|
|
127
140
|
maxPendingAbstractions: 5,
|
|
128
|
-
scanDirectories: ['
|
|
141
|
+
scanDirectories: ['src'],
|
|
129
142
|
backlogItemsDir: '.lumpcode/lumps/abstractionImplementer/backlogItems',
|
|
130
143
|
command: 'cursor',
|
|
131
144
|
lumpVariables: { model: 'composer-2.5' },
|
|
132
145
|
discoveryBranch: 'dev',
|
|
146
|
+
scanCommand: 'npx fallow dupes --mode semantic --format json > .lumpcode/dupes.json',
|
|
133
147
|
});
|
|
134
148
|
```
|
|
135
149
|
|
|
150
|
+
`maxPendingAbstractions` caps unmerged `todo/` items (one finder context per tick while under the cap). `scanCommand` runs before the default prompt. Pass `steps` to replace the prompt (the scanner still prepends). `configUrl: import.meta.url` is required so the recipe can count pending items from the project root.
|
|
151
|
+
|
|
136
152
|
```ts
|
|
137
153
|
// .lumpcode/lumps/abstractionImplementer/config.ts
|
|
138
154
|
import {
|
package/dist/index.cjs
CHANGED
|
@@ -8,6 +8,7 @@ var path$1 = require('node:path');
|
|
|
8
8
|
var node_url = require('node:url');
|
|
9
9
|
var fs = require('fs/promises');
|
|
10
10
|
var jsYaml = require('js-yaml');
|
|
11
|
+
var fs$1 = require('node:fs/promises');
|
|
11
12
|
|
|
12
13
|
/** Run a shell script via `sh -c` (portable on Unix-like systems and Git Bash on Windows). */
|
|
13
14
|
function shellCommand(script) {
|
|
@@ -17,6 +18,111 @@ function shellCommand(script) {
|
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
const LOG_PREFIX = '[lumpcode/recipes]';
|
|
22
|
+
async function openGithubPr(input) {
|
|
23
|
+
const { workspacePath, baseBranch, branchName, title, body } = input;
|
|
24
|
+
const cwd = workspacePath;
|
|
25
|
+
const listed = await core.execBinary({
|
|
26
|
+
binaryPath: 'gh',
|
|
27
|
+
args: ['pr', 'list', '--head', branchName, '--base', baseBranch, '--json', 'number'],
|
|
28
|
+
cwd,
|
|
29
|
+
});
|
|
30
|
+
if (listed.success && githubPrListHasItems(listed.data.stdout)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const created = await core.execBinary({
|
|
34
|
+
binaryPath: 'gh',
|
|
35
|
+
args: [
|
|
36
|
+
'pr',
|
|
37
|
+
'create',
|
|
38
|
+
'--base',
|
|
39
|
+
baseBranch,
|
|
40
|
+
'--head',
|
|
41
|
+
branchName,
|
|
42
|
+
'--title',
|
|
43
|
+
title,
|
|
44
|
+
'--body',
|
|
45
|
+
body,
|
|
46
|
+
],
|
|
47
|
+
cwd,
|
|
48
|
+
});
|
|
49
|
+
if (!created.success) {
|
|
50
|
+
console.error(`${LOG_PREFIX} gh pr create failed: ${created.data.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function githubPrListHasItems(stdout) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(stdout);
|
|
56
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const LUMP_BRANCH_PREFIX = 'lump/';
|
|
64
|
+
const OPEN_PR_PROVIDERS = ['github'];
|
|
65
|
+
function openPrPostTeardown(options) {
|
|
66
|
+
const { provider, lumpName: lumpNameOption, title, body } = options;
|
|
67
|
+
return async (input) => {
|
|
68
|
+
const { baseBranch, branchName, contextList, workspacePath } = input;
|
|
69
|
+
if (!branchName || branchName === baseBranch) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const remote = await core.execBinary({
|
|
73
|
+
binaryPath: 'git',
|
|
74
|
+
args: ['ls-remote', '--heads', 'origin', branchName],
|
|
75
|
+
cwd: workspacePath,
|
|
76
|
+
});
|
|
77
|
+
if (!remote.success || !remote.data.stdout.trim()) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const names = contextList.map((ctx) => ctx.name).filter((name) => name.length > 0);
|
|
81
|
+
const label = names.length > 0 ? names.join(', ') : branchName;
|
|
82
|
+
const lumpName = lumpNameOption ?? lumpNameFromBranch(branchName);
|
|
83
|
+
const resolvedTitle = title?.(input) ?? defaultPrTitle({ lumpName, label });
|
|
84
|
+
const resolvedBody = body?.(input) ?? `LUMP contexts: ${label}`;
|
|
85
|
+
await openPrWithProvider({
|
|
86
|
+
provider,
|
|
87
|
+
workspacePath,
|
|
88
|
+
baseBranch,
|
|
89
|
+
branchName,
|
|
90
|
+
title: resolvedTitle,
|
|
91
|
+
body: resolvedBody,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function defaultPrTitle(input) {
|
|
96
|
+
const { lumpName, label } = input;
|
|
97
|
+
if (lumpName) {
|
|
98
|
+
return cliUtils.getGitCommitMessage({ lumpName, contextName: label });
|
|
99
|
+
}
|
|
100
|
+
return `LUMP: ${label}`;
|
|
101
|
+
}
|
|
102
|
+
function lumpNameFromBranch(branchName) {
|
|
103
|
+
if (!branchName.startsWith(LUMP_BRANCH_PREFIX)) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const rest = branchName.slice(LUMP_BRANCH_PREFIX.length);
|
|
107
|
+
const slash = rest.indexOf('/');
|
|
108
|
+
if (slash <= 0) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return rest.slice(0, slash);
|
|
112
|
+
}
|
|
113
|
+
async function openPrWithProvider(input) {
|
|
114
|
+
const { provider, ...prInput } = input;
|
|
115
|
+
switch (provider) {
|
|
116
|
+
case 'github':
|
|
117
|
+
await openGithubPr(prInput);
|
|
118
|
+
return;
|
|
119
|
+
default: {
|
|
120
|
+
const _exhaustive = provider;
|
|
121
|
+
throw new Error(`Unhandled PR provider: ${_exhaustive}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
20
126
|
function normalizeMaybePromGetter(maybePromGetter, defaultValue) {
|
|
21
127
|
if (typeof maybePromGetter === 'function') {
|
|
22
128
|
return maybePromGetter;
|
|
@@ -286,10 +392,26 @@ async function listTodoFolderNames(todoDir) {
|
|
|
286
392
|
throw error;
|
|
287
393
|
}
|
|
288
394
|
}
|
|
395
|
+
/** Path relative to `todo/`, using `/` so it is stable in context variables. */
|
|
396
|
+
async function listTodoRelativeDirs(todoDir) {
|
|
397
|
+
const topNames = await listTodoFolderNames(todoDir);
|
|
398
|
+
const relativeDirs = [];
|
|
399
|
+
for (const name of topNames) {
|
|
400
|
+
const ticketNames = await listTodoFolderNames(path$1.join(todoDir, name, 'tickets'));
|
|
401
|
+
if (ticketNames.length === 0) {
|
|
402
|
+
relativeDirs.push(name);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
for (const ticketName of ticketNames) {
|
|
406
|
+
relativeDirs.push(`${name}/tickets/${ticketName}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return relativeDirs;
|
|
410
|
+
}
|
|
289
411
|
function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
290
412
|
return async () => {
|
|
291
413
|
const todoDir = path$1.join(backlogItemsDir, 'todo');
|
|
292
|
-
const folderNames = await
|
|
414
|
+
const folderNames = await listTodoRelativeDirs(todoDir);
|
|
293
415
|
const discovered = await Promise.all(folderNames.map(async (folderName) => {
|
|
294
416
|
const descPath = path$1.join(todoDir, folderName, 'desc.yml');
|
|
295
417
|
let rawText;
|
|
@@ -304,13 +426,22 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
|
304
426
|
throw error;
|
|
305
427
|
}
|
|
306
428
|
const raw = jsYaml.load(rawText);
|
|
429
|
+
const itemFolderName = path$1.basename(folderName);
|
|
307
430
|
const baseItem = validateBaseBacklogItem(raw, `in folder "${folderName}"`);
|
|
308
|
-
if (baseItem.name !==
|
|
431
|
+
if (baseItem.name !== itemFolderName) {
|
|
309
432
|
throw new Error(`Backlog item folder "${folderName}" desc.yml name "${baseItem.name}" must match folder name`);
|
|
310
433
|
}
|
|
311
434
|
const item = parseItem ? parseItem(baseItem, folderName, raw) : baseItem;
|
|
312
435
|
return { item, folderName };
|
|
313
436
|
}));
|
|
437
|
+
const seenNames = new Map();
|
|
438
|
+
for (const { item, folderName } of discovered) {
|
|
439
|
+
const previous = seenNames.get(item.name);
|
|
440
|
+
if (previous !== undefined) {
|
|
441
|
+
throw new Error(`Duplicate backlog item name "${item.name}" in folders "${previous}" and "${folderName}"`);
|
|
442
|
+
}
|
|
443
|
+
seenNames.set(item.name, folderName);
|
|
444
|
+
}
|
|
314
445
|
discovered.sort((a, b) => {
|
|
315
446
|
if (a.item.priority !== b.item.priority) {
|
|
316
447
|
return a.item.priority - b.item.priority;
|
|
@@ -341,7 +472,7 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
|
341
472
|
function isPlainObject(value) {
|
|
342
473
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
343
474
|
}
|
|
344
|
-
/** Moves a finished backlog item folder from todo/ to
|
|
475
|
+
/** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
|
|
345
476
|
function folderSetTaskDoneStep(input) {
|
|
346
477
|
const nameVarName = input.nameVarName ?? 'TASK_NAME';
|
|
347
478
|
return {
|
|
@@ -349,19 +480,29 @@ function folderSetTaskDoneStep(input) {
|
|
|
349
480
|
const variables = context.variables;
|
|
350
481
|
const itemsDirRelative = variables[input.itemsDirVarName];
|
|
351
482
|
const taskName = variables[nameVarName];
|
|
352
|
-
|
|
483
|
+
const itemDirRelative = variables.BACKLOG_ITEM_DIR;
|
|
484
|
+
if (!itemsDirRelative || (!itemDirRelative && !taskName)) {
|
|
353
485
|
throw new Error('Backlog items directory and task name are required');
|
|
354
486
|
}
|
|
355
487
|
const itemsDir = path$1.join(workspacePath, itemsDirRelative);
|
|
356
|
-
const
|
|
357
|
-
const
|
|
488
|
+
const todoDir = path$1.join(itemsDir, 'todo');
|
|
489
|
+
const fromDir = itemDirRelative
|
|
490
|
+
? path$1.join(workspacePath, itemDirRelative)
|
|
491
|
+
: path$1.join(todoDir, taskName);
|
|
492
|
+
const relativeFromTodo = path$1.relative(todoDir, fromDir);
|
|
493
|
+
if (relativeFromTodo === '' ||
|
|
494
|
+
relativeFromTodo.startsWith('..') ||
|
|
495
|
+
path$1.isAbsolute(relativeFromTodo)) {
|
|
496
|
+
throw new Error(`BACKLOG_ITEM_DIR must be a folder under ${todoDir}: ${itemDirRelative ?? fromDir}`);
|
|
497
|
+
}
|
|
498
|
+
const toDir = path$1.join(itemsDir, 'completed', relativeFromTodo);
|
|
358
499
|
const descPath = path$1.join(fromDir, 'desc.yml');
|
|
359
500
|
const completedDescPath = path$1.join(toDir, 'desc.yml');
|
|
360
501
|
if (!(await core.pathExists(fromDir))) {
|
|
361
502
|
return null;
|
|
362
503
|
}
|
|
363
504
|
if (await core.pathExists(toDir)) {
|
|
364
|
-
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName}": already exists at ${toDir}`);
|
|
505
|
+
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
|
|
365
506
|
return null;
|
|
366
507
|
}
|
|
367
508
|
const rawText = await fs.readFile(descPath, 'utf-8');
|
|
@@ -373,7 +514,7 @@ function folderSetTaskDoneStep(input) {
|
|
|
373
514
|
...raw,
|
|
374
515
|
completedAt: new Date().toISOString(),
|
|
375
516
|
};
|
|
376
|
-
await fs.mkdir(path$1.
|
|
517
|
+
await fs.mkdir(path$1.dirname(toDir), { recursive: true });
|
|
377
518
|
await fs.rename(fromDir, toDir);
|
|
378
519
|
await fs.writeFile(completedDescPath, jsYaml.dump(updated));
|
|
379
520
|
return {
|
|
@@ -622,29 +763,72 @@ const abstractionBacklog = defineRecipe(function abstractionBacklog(options) {
|
|
|
622
763
|
});
|
|
623
764
|
});
|
|
624
765
|
|
|
766
|
+
function scanCommandStep(scanCommand) {
|
|
767
|
+
if (typeof scanCommand === 'function') {
|
|
768
|
+
return { commandFn: scanCommand };
|
|
769
|
+
}
|
|
770
|
+
if (typeof scanCommand === 'string') {
|
|
771
|
+
return { commandFn: () => shellCommand(scanCommand) };
|
|
772
|
+
}
|
|
773
|
+
return { commandFn: () => scanCommand };
|
|
774
|
+
}
|
|
625
775
|
const abstractionFinder = defineRecipe(function abstractionFinder(options) {
|
|
626
|
-
const { maxPendingAbstractions = 5, scanDirectories, customPrompt, backlogItemsDir,
|
|
776
|
+
const { configUrl, maxPendingAbstractions = 5, scanDirectories, utilDir, customPrompt, backlogItemsDir, scanCommand, steps: stepsOverride, ...rest } = options;
|
|
777
|
+
if (path$1.isAbsolute(backlogItemsDir)) {
|
|
778
|
+
throw new Error(`backlogItemsDir must be project-root-relative, not absolute: ${backlogItemsDir}`);
|
|
779
|
+
}
|
|
780
|
+
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
781
|
+
const todoDir = path$1.join(projectRoot, backlogItemsDir, 'todo');
|
|
782
|
+
const agentSteps = stepsOverride ?? buildFinderPrompt({
|
|
783
|
+
backlogItemsDir,
|
|
784
|
+
scanDirectories,
|
|
785
|
+
utilDir,
|
|
786
|
+
customPrompt,
|
|
787
|
+
});
|
|
788
|
+
const steps = [
|
|
789
|
+
...(scanCommand === undefined ? [] : [scanCommandStep(scanCommand)]),
|
|
790
|
+
...cliUtils.normalizeSteps({
|
|
791
|
+
prompt: undefined,
|
|
792
|
+
jsSteps: agentSteps,
|
|
793
|
+
}),
|
|
794
|
+
];
|
|
627
795
|
return cliUtils.defineConfig({
|
|
628
|
-
...
|
|
796
|
+
...rest,
|
|
629
797
|
getContextListFn: ephemeralContextListFn({
|
|
630
|
-
contextCount
|
|
798
|
+
async contextCount() {
|
|
799
|
+
const pending = (await listTodoRelativeDirs(todoDir)).length;
|
|
800
|
+
return pending >= maxPendingAbstractions ? 0 : 1;
|
|
801
|
+
},
|
|
631
802
|
variables: {
|
|
632
803
|
BACKLOG_ITEMS_DIR: backlogItemsDir,
|
|
633
804
|
},
|
|
634
805
|
}),
|
|
635
|
-
steps
|
|
806
|
+
steps,
|
|
636
807
|
});
|
|
637
808
|
});
|
|
638
|
-
function buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt, }) {
|
|
639
|
-
|
|
640
|
-
|
|
809
|
+
function buildFinderPrompt({ backlogItemsDir, scanDirectories, utilDir, customPrompt, }) {
|
|
810
|
+
if (customPrompt) {
|
|
811
|
+
return customPrompt();
|
|
812
|
+
}
|
|
813
|
+
const scanLabel = scanDirectories && scanDirectories.length > 0
|
|
814
|
+
? scanDirectories.map((dir) => `@${dir}`).join(' and ')
|
|
815
|
+
: 'the codebase';
|
|
816
|
+
const materializeDir = utilDir ?? scanDirectories?.[0];
|
|
817
|
+
const materializeLine = materializeDir
|
|
818
|
+
? `- Would materialize as a new util under @${materializeDir}/<utilName>/, following existing conventions there.`
|
|
819
|
+
: '- Would materialize as a new util in the scanned tree, following existing conventions there.';
|
|
820
|
+
const refactorScope = scanDirectories && scanDirectories.length > 0
|
|
821
|
+
? scanDirectories.join(' and ')
|
|
822
|
+
: 'the scanned tree';
|
|
823
|
+
return `
|
|
824
|
+
Scan ${scanLabel} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
|
|
641
825
|
|
|
642
826
|
List existing backlog item names under @${backlogItemsDir}/todo/ and @${backlogItemsDir}/completed/. Do not propose abstractions whose util name already appears in either directory.
|
|
643
827
|
|
|
644
828
|
Pick exactly one new abstraction that:
|
|
645
829
|
- Has a clear util name matching ^[a-zA-Z0-9_-]+$ that describes the pattern it captures.
|
|
646
|
-
|
|
647
|
-
- Would shrink the codebase: refactoring all call sites in
|
|
830
|
+
${materializeLine}
|
|
831
|
+
- Would shrink the codebase: refactoring all call sites in ${refactorScope} should reduce net line count (excluding new unit tests).
|
|
648
832
|
|
|
649
833
|
Create exactly one new backlog item folder at @${backlogItemsDir}/todo/<utilName>/ with:
|
|
650
834
|
- desc.yml containing:
|
|
@@ -664,6 +848,11 @@ Do not take too much time looking for every possible abstraction. Once you found
|
|
|
664
848
|
`.trim();
|
|
665
849
|
}
|
|
666
850
|
|
|
851
|
+
const FEATURE_BACKLOG_WORKFLOWS = [
|
|
852
|
+
'tdd',
|
|
853
|
+
'directImpl',
|
|
854
|
+
'manual',
|
|
855
|
+
];
|
|
667
856
|
const RESERVED_NAME_SUFFIXES = ['_req', '_testPlan', '_tests_impl'];
|
|
668
857
|
function assertValidFeatureItemName(name) {
|
|
669
858
|
for (const suffix of RESERVED_NAME_SUFFIXES) {
|
|
@@ -672,6 +861,9 @@ function assertValidFeatureItemName(name) {
|
|
|
672
861
|
}
|
|
673
862
|
}
|
|
674
863
|
}
|
|
864
|
+
function featureItemContextBaseName(item) {
|
|
865
|
+
return item.parentName ? `${item.parentName}-${item.name}` : item.name;
|
|
866
|
+
}
|
|
675
867
|
function featureContextName(itemName, stage) {
|
|
676
868
|
switch (stage) {
|
|
677
869
|
case 'makeReq':
|
|
@@ -681,6 +873,7 @@ function featureContextName(itemName, stage) {
|
|
|
681
873
|
case 'testImpl':
|
|
682
874
|
return `${itemName}_tests_impl`;
|
|
683
875
|
case 'implementation':
|
|
876
|
+
case 'directImpl':
|
|
684
877
|
return itemName;
|
|
685
878
|
default: {
|
|
686
879
|
const _exhaustive = stage;
|
|
@@ -688,9 +881,84 @@ function featureContextName(itemName, stage) {
|
|
|
688
881
|
}
|
|
689
882
|
}
|
|
690
883
|
}
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
|
|
884
|
+
function parentNameFromTodoRelativeDir(todoRelativeDir) {
|
|
885
|
+
const parts = todoRelativeDir.split('/');
|
|
886
|
+
if (parts.length === 3 && parts[1] === 'tickets') {
|
|
887
|
+
return parts[0];
|
|
888
|
+
}
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
891
|
+
function parseFeatureWorkflow(itemName, raw) {
|
|
892
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
893
|
+
return undefined;
|
|
894
|
+
}
|
|
895
|
+
const record = raw;
|
|
896
|
+
if (record.workflow === undefined) {
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
if (typeof record.workflow !== 'string' ||
|
|
900
|
+
!FEATURE_BACKLOG_WORKFLOWS.includes(record.workflow)) {
|
|
901
|
+
throw new Error(`Backlog item "${itemName}" field "workflow" must be one of: ${FEATURE_BACKLOG_WORKFLOWS.join(', ')}`);
|
|
902
|
+
}
|
|
903
|
+
return record.workflow;
|
|
904
|
+
}
|
|
905
|
+
async function resolveItemWorkflow(input) {
|
|
906
|
+
const { item, paths, projectRoot } = input;
|
|
907
|
+
if (item.workflow !== undefined) {
|
|
908
|
+
return item.workflow;
|
|
909
|
+
}
|
|
910
|
+
if (item.parentName === undefined) {
|
|
911
|
+
return 'tdd';
|
|
912
|
+
}
|
|
913
|
+
const parentDescPath = path$1.join(projectRoot, paths.backlogItemsDir, 'todo', item.parentName, 'desc.yml');
|
|
914
|
+
let rawText;
|
|
915
|
+
try {
|
|
916
|
+
rawText = await fs$1.readFile(parentDescPath, 'utf-8');
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
const err = error;
|
|
920
|
+
if (err.code === 'ENOENT') {
|
|
921
|
+
return 'tdd';
|
|
922
|
+
}
|
|
923
|
+
throw error;
|
|
924
|
+
}
|
|
925
|
+
return parseFeatureWorkflow(item.parentName, jsYaml.load(rawText)) ?? 'tdd';
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* `dev` → only top-level `directImpl` (tickets never run on `dev`, even if `directImpl`).
|
|
929
|
+
* `feature/<key>` → exact item name, or the parent todo name for tickets.
|
|
930
|
+
* `manual` never reaches here (`resolveFeatureBacklogItem` ignores it first).
|
|
931
|
+
*/
|
|
932
|
+
function itemMatchesDiscoveryBranch(input) {
|
|
933
|
+
const { itemName, parentName, discoveryBranch, workflow } = input;
|
|
934
|
+
if (discoveryBranch === 'dev') {
|
|
935
|
+
return workflow === 'directImpl' && parentName === undefined;
|
|
936
|
+
}
|
|
937
|
+
if (!discoveryBranch.startsWith('feature/')) {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
const key = discoveryBranch.slice('feature/'.length);
|
|
941
|
+
return (parentName ?? itemName) === key;
|
|
942
|
+
}
|
|
943
|
+
async function resolveFeatureBacklogItem(input) {
|
|
944
|
+
const { item, paths, projectRoot, discoveryBranch } = input;
|
|
945
|
+
const contextBaseName = featureItemContextBaseName(item);
|
|
946
|
+
const workflow = await resolveItemWorkflow({ item, paths, projectRoot });
|
|
947
|
+
if (workflow === 'manual') {
|
|
948
|
+
return { ignored: true };
|
|
949
|
+
}
|
|
950
|
+
if (!!item.completedAt ||
|
|
951
|
+
!itemMatchesDiscoveryBranch({
|
|
952
|
+
itemName: item.name,
|
|
953
|
+
parentName: item.parentName,
|
|
954
|
+
discoveryBranch,
|
|
955
|
+
workflow,
|
|
956
|
+
})) {
|
|
957
|
+
return { ignored: true };
|
|
958
|
+
}
|
|
959
|
+
const itemDir = path$1.join(paths.backlogItemsDir, 'todo', item.todoRelativeDir);
|
|
960
|
+
const reqFilePath = path$1.join(itemDir, 'requirements.md');
|
|
961
|
+
const testPlanFilePath = path$1.join(itemDir, 'testPlan.md');
|
|
694
962
|
const hasReq = await core.pathExists(path$1.join(projectRoot, reqFilePath));
|
|
695
963
|
if (!hasReq) {
|
|
696
964
|
if (item.manualReq === true) {
|
|
@@ -698,7 +966,14 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
698
966
|
}
|
|
699
967
|
return {
|
|
700
968
|
stage: 'makeReq',
|
|
701
|
-
contextName: featureContextName(
|
|
969
|
+
contextName: featureContextName(contextBaseName, 'makeReq'),
|
|
970
|
+
variables: { REQ_FILE: reqFilePath },
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (workflow === 'directImpl') {
|
|
974
|
+
return {
|
|
975
|
+
stage: 'directImpl',
|
|
976
|
+
contextName: featureContextName(contextBaseName, 'directImpl'),
|
|
702
977
|
variables: { REQ_FILE: reqFilePath },
|
|
703
978
|
};
|
|
704
979
|
}
|
|
@@ -706,24 +981,24 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
706
981
|
if (!hasTestPlan) {
|
|
707
982
|
return {
|
|
708
983
|
stage: 'makeTestPlan',
|
|
709
|
-
contextName: featureContextName(
|
|
984
|
+
contextName: featureContextName(contextBaseName, 'makeTestPlan'),
|
|
710
985
|
variables: {
|
|
711
986
|
REQ_FILE: reqFilePath,
|
|
712
987
|
TEST_PLAN_FILE: testPlanFilePath,
|
|
713
988
|
},
|
|
714
989
|
};
|
|
715
990
|
}
|
|
716
|
-
const testsImplContextName = featureContextName(
|
|
991
|
+
const testsImplContextName = featureContextName(contextBaseName, 'testImpl');
|
|
717
992
|
const testsImplStatus = await cliUtils.getContextStatus({
|
|
718
993
|
projectRoot,
|
|
719
994
|
contextName: testsImplContextName,
|
|
720
|
-
lumpName,
|
|
721
|
-
baseBranch,
|
|
995
|
+
lumpName: paths.lumpName,
|
|
996
|
+
baseBranch: discoveryBranch,
|
|
722
997
|
});
|
|
723
998
|
if (testsImplStatus === 'finished') {
|
|
724
999
|
return {
|
|
725
1000
|
stage: 'implementation',
|
|
726
|
-
contextName: featureContextName(
|
|
1001
|
+
contextName: featureContextName(contextBaseName, 'implementation'),
|
|
727
1002
|
variables: {
|
|
728
1003
|
REQ_FILE: reqFilePath,
|
|
729
1004
|
TEST_PLAN_FILE: testPlanFilePath,
|
|
@@ -743,27 +1018,38 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
743
1018
|
};
|
|
744
1019
|
}
|
|
745
1020
|
const featureBacklog = defineRecipe(function featureBacklog(options) {
|
|
746
|
-
const { configUrl,
|
|
1021
|
+
const { configUrl, implValidateCommand, backlogItemsDir, ...rest } = options;
|
|
747
1022
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
748
1023
|
const runImplValidation = resolveImplValidateCommand(implValidateCommand ??
|
|
749
1024
|
'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
750
1025
|
return backlog({
|
|
751
1026
|
configUrl,
|
|
752
1027
|
backlogItemsDir,
|
|
753
|
-
|
|
754
|
-
parseItem(baseItem, _folderName, raw) {
|
|
1028
|
+
parseItem(baseItem, folderName, raw) {
|
|
755
1029
|
assertValidFeatureItemName(baseItem.name);
|
|
756
1030
|
const record = raw;
|
|
757
1031
|
if (record.manualReq !== undefined && typeof record.manualReq !== 'boolean') {
|
|
758
1032
|
throw new Error(`Backlog item "${baseItem.name}" field "manualReq" must be a boolean`);
|
|
759
1033
|
}
|
|
1034
|
+
const parentName = parentNameFromTodoRelativeDir(folderName);
|
|
760
1035
|
return {
|
|
761
1036
|
...baseItem,
|
|
1037
|
+
todoRelativeDir: folderName,
|
|
1038
|
+
parentName,
|
|
1039
|
+
dependsOn: parentName
|
|
1040
|
+
? baseItem.dependsOn?.map((dep) => `${parentName}-${dep}`)
|
|
1041
|
+
: baseItem.dependsOn,
|
|
762
1042
|
manualReq: record.manualReq === true ? true : undefined,
|
|
1043
|
+
workflow: parseFeatureWorkflow(baseItem.name, raw),
|
|
763
1044
|
};
|
|
764
1045
|
},
|
|
765
|
-
async resolveItem({ item, paths }) {
|
|
766
|
-
return resolveFeatureBacklogItem(
|
|
1046
|
+
async resolveItem({ item, paths, discoveryBranch }) {
|
|
1047
|
+
return resolveFeatureBacklogItem({
|
|
1048
|
+
item,
|
|
1049
|
+
paths,
|
|
1050
|
+
projectRoot,
|
|
1051
|
+
discoveryBranch,
|
|
1052
|
+
});
|
|
767
1053
|
},
|
|
768
1054
|
stages: {
|
|
769
1055
|
makeReq: {
|
|
@@ -912,6 +1198,25 @@ The implementation should make the tests pass. Do not edit any test file except
|
|
|
912
1198
|
validationCommandFn: runImplValidation,
|
|
913
1199
|
}),
|
|
914
1200
|
},
|
|
1201
|
+
directImpl: {
|
|
1202
|
+
completion: 'moveToDone',
|
|
1203
|
+
steps: retryUntilGreen({
|
|
1204
|
+
steps: [
|
|
1205
|
+
{
|
|
1206
|
+
promptFn({ context: ctx }) {
|
|
1207
|
+
const vars = ctx.variables;
|
|
1208
|
+
const { REQ_FILE } = vars;
|
|
1209
|
+
return `
|
|
1210
|
+
Implement the feature described in @${REQ_FILE}.
|
|
1211
|
+
Add or update tests as needed so the suite covers the change, and make validation pass.
|
|
1212
|
+
Do not edit @${REQ_FILE} unless absolutely necessary.
|
|
1213
|
+
`.trim();
|
|
1214
|
+
},
|
|
1215
|
+
},
|
|
1216
|
+
],
|
|
1217
|
+
validationCommandFn: runImplValidation,
|
|
1218
|
+
}),
|
|
1219
|
+
},
|
|
915
1220
|
},
|
|
916
1221
|
...rest,
|
|
917
1222
|
});
|
|
@@ -922,6 +1227,8 @@ exports.BACKLOG_ITEM_DIR_VAR = BACKLOG_ITEM_DIR_VAR;
|
|
|
922
1227
|
exports.BACKLOG_STAGE_VAR = BACKLOG_STAGE_VAR;
|
|
923
1228
|
exports.BACKLOG_TASK_NAME_VAR = BACKLOG_TASK_NAME_VAR;
|
|
924
1229
|
exports.BACKLOG_TASK_VAR = BACKLOG_TASK_VAR;
|
|
1230
|
+
exports.FEATURE_BACKLOG_WORKFLOWS = FEATURE_BACKLOG_WORKFLOWS;
|
|
1231
|
+
exports.OPEN_PR_PROVIDERS = OPEN_PR_PROVIDERS;
|
|
925
1232
|
exports.abstractionBacklog = abstractionBacklog;
|
|
926
1233
|
exports.abstractionFinder = abstractionFinder;
|
|
927
1234
|
exports.backlog = backlog;
|
|
@@ -932,8 +1239,11 @@ exports.featureBacklog = featureBacklog;
|
|
|
932
1239
|
exports.folderBacklogContexts = folderBacklogContexts;
|
|
933
1240
|
exports.folderSetTaskDoneStep = folderSetTaskDoneStep;
|
|
934
1241
|
exports.getRecursiveSteps = getRecursiveSteps;
|
|
1242
|
+
exports.listTodoRelativeDirs = listTodoRelativeDirs;
|
|
935
1243
|
exports.lumpPathAndName = lumpPathAndName;
|
|
936
1244
|
exports.normalizeMaybePromGetter = normalizeMaybePromGetter;
|
|
1245
|
+
exports.openPrPostTeardown = openPrPostTeardown;
|
|
1246
|
+
exports.parseFeatureWorkflow = parseFeatureWorkflow;
|
|
937
1247
|
exports.projectRootFromConfigUrl = projectRootFromConfigUrl;
|
|
938
1248
|
exports.requireArtifactStep = requireArtifactStep;
|
|
939
1249
|
exports.resolveBacklogPaths = resolveBacklogPaths;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { CommandDescriptor, MaybePromise, PostCommandExecFn, Step } from '@lumpcode/core';
|
|
2
|
-
import { LumpVariables, GetContextListFnInput, GetContextListFn, StepVariables, CommandFn, LumpJsConfig, LumpJsConfigSteps, MaybePromise as MaybePromise$1, Context } from '@lumpcode/cli-utils';
|
|
2
|
+
import { LumpVariables, PostSetupWorkspaceFnInput, PostTeardownWorkspaceFn, GetContextListFnInput, GetContextListFn, StepVariables, CommandFn, LumpJsConfig, LumpJsConfigSteps, MaybePromise as MaybePromise$1, Context } from '@lumpcode/cli-utils';
|
|
3
3
|
|
|
4
4
|
/** Run a shell script via `sh -c` (portable on Unix-like systems and Git Bash on Windows). */
|
|
5
5
|
declare function shellCommand(script: string): CommandDescriptor;
|
|
6
6
|
|
|
7
|
+
declare const OPEN_PR_PROVIDERS: readonly ["github"];
|
|
8
|
+
type OpenPrProvider = (typeof OPEN_PR_PROVIDERS)[number];
|
|
9
|
+
type OpenPrPostTeardownOptions<V extends LumpVariables = LumpVariables> = {
|
|
10
|
+
provider: OpenPrProvider;
|
|
11
|
+
/** Overrides the lump name parsed from `lump/<lumpName>/…` branches. */
|
|
12
|
+
lumpName?: string;
|
|
13
|
+
title?: (input: PostSetupWorkspaceFnInput<V>) => string;
|
|
14
|
+
body?: (input: PostSetupWorkspaceFnInput<V>) => string;
|
|
15
|
+
};
|
|
16
|
+
declare function openPrPostTeardown<V extends LumpVariables = LumpVariables>(options: OpenPrPostTeardownOptions<V>): PostTeardownWorkspaceFn<V>;
|
|
17
|
+
|
|
7
18
|
type MaybePromGetter<T, P = void> = T | ((input: P) => MaybePromise<T>);
|
|
8
19
|
declare function normalizeMaybePromGetter<T, P = void>(maybePromGetter: MaybePromGetter<T, P>): (input: P) => MaybePromise<T>;
|
|
9
20
|
declare function normalizeMaybePromGetter<T, P = void>(maybePromGetter: MaybePromGetter<T, P> | undefined, defaultValue: T): (input?: P) => MaybePromise<T>;
|
|
@@ -92,15 +103,21 @@ type DoneBacklogItem<T extends BaseBacklogItem = BaseBacklogItem> = T & {
|
|
|
92
103
|
|
|
93
104
|
type FolderBacklogContextsOptions<Item extends BaseBacklogItem = BaseBacklogItem> = {
|
|
94
105
|
backlogItemsDir: string;
|
|
106
|
+
/**
|
|
107
|
+
* `folderName` is the path relative to `todo/`: the item folder, or
|
|
108
|
+
* `<parent>/tickets/<ticket>` when the parent has a `tickets/` directory.
|
|
109
|
+
*/
|
|
95
110
|
parseItem?: (item: BaseBacklogItem, folderName: string, raw: unknown) => Item;
|
|
96
111
|
parseContext?: (item: Item, folderName: string) => MaybePromise$1<{
|
|
97
112
|
parsed?: Partial<Context>;
|
|
98
113
|
ignored?: boolean;
|
|
99
114
|
}>;
|
|
100
115
|
};
|
|
116
|
+
/** Path relative to `todo/`, using `/` so it is stable in context variables. */
|
|
117
|
+
declare function listTodoRelativeDirs(todoDir: string): Promise<string[]>;
|
|
101
118
|
declare function folderBacklogContexts<Item extends BaseBacklogItem = BaseBacklogItem, V extends LumpVariables = LumpVariables>({ backlogItemsDir, parseItem, parseContext, }: FolderBacklogContextsOptions<Item>): GetContextListFn<V>;
|
|
102
119
|
|
|
103
|
-
/** Moves a finished backlog item folder from todo/ to
|
|
120
|
+
/** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
|
|
104
121
|
declare function folderSetTaskDoneStep<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(input: {
|
|
105
122
|
itemsDirVarName: string;
|
|
106
123
|
nameVarName?: string;
|
|
@@ -136,12 +153,20 @@ type AbstractionBacklogOptions<V extends LumpVariables = LumpVariables, SV exten
|
|
|
136
153
|
} & Omit<LumpJsConfig<V, SV>, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt' | 'steps'>;
|
|
137
154
|
declare const abstractionBacklog: <V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(options: AbstractionBacklogOptions<V, SV>) => LumpJsConfig<V, SV>;
|
|
138
155
|
|
|
156
|
+
type AbstractionFinderScanCommand<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = string | CommandDescriptor | CommandFn<V, SV>;
|
|
139
157
|
type AbstractionFinderOptions<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = {
|
|
158
|
+
/** Lump config module URL — pass `import.meta.url` from `config.ts`. */
|
|
159
|
+
configUrl: string | URL;
|
|
140
160
|
scanDirectories?: string[];
|
|
161
|
+
/** Optional util output directory (project-root-relative). Defaults to the first scan directory. */
|
|
162
|
+
utilDir?: string;
|
|
141
163
|
customPrompt?(): string;
|
|
164
|
+
/** Max unmerged items under `backlogItemsDir/todo/`. Finder emits one context per tick while under the cap. */
|
|
142
165
|
maxPendingAbstractions?: number;
|
|
143
166
|
backlogItemsDir: string;
|
|
144
|
-
|
|
167
|
+
/** Optional scanner command prepended before the prompt (e.g. a dupes report). */
|
|
168
|
+
scanCommand?: AbstractionFinderScanCommand<V, SV>;
|
|
169
|
+
} & Omit<LumpJsConfig<V, SV>, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt'>;
|
|
145
170
|
declare const abstractionFinder: <V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(options: AbstractionFinderOptions<V, SV>) => LumpJsConfig<V, SV>;
|
|
146
171
|
|
|
147
172
|
type BacklogStageDefinition<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = {
|
|
@@ -178,10 +203,18 @@ declare const BACKLOG_ITEM_DIR_VAR = "BACKLOG_ITEM_DIR";
|
|
|
178
203
|
declare function backlog<Item extends BaseBacklogItem, V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables, Stages extends Record<string, BacklogStageDefinition<V, SV>> = Record<string, BacklogStageDefinition<V, SV>>>(options: BacklogOptions<Item, V, SV, Stages>): LumpJsConfig<V, SV>;
|
|
179
204
|
declare const backlogRecipe: typeof backlog;
|
|
180
205
|
|
|
206
|
+
type FeatureBacklogWorkflow = 'tdd' | 'directImpl' | 'manual';
|
|
207
|
+
type FeatureBacklogRunnableWorkflow = Exclude<FeatureBacklogWorkflow, 'manual'>;
|
|
181
208
|
type FeatureBacklogItem = BaseBacklogItem & {
|
|
182
209
|
manualReq?: boolean;
|
|
210
|
+
workflow?: FeatureBacklogWorkflow;
|
|
211
|
+
completedAt?: string;
|
|
212
|
+
/** Path relative to `backlogItems/todo/`; tickets live at `<parent>/tickets/<name>`. */
|
|
213
|
+
todoRelativeDir: string;
|
|
214
|
+
/** Parent todo folder name when this item is a ticket. */
|
|
215
|
+
parentName?: string;
|
|
183
216
|
};
|
|
184
|
-
type FeatureBacklogStage = 'makeReq' | 'makeTestPlan' | 'testImpl' | 'implementation';
|
|
217
|
+
type FeatureBacklogStage = 'makeReq' | 'makeTestPlan' | 'testImpl' | 'implementation' | 'directImpl';
|
|
185
218
|
type FeatureBacklogContextVariables = {
|
|
186
219
|
TASK_NAME: string;
|
|
187
220
|
TASK: string;
|
|
@@ -193,12 +226,18 @@ type FeatureBacklogContextVariables = {
|
|
|
193
226
|
};
|
|
194
227
|
type FeatureBacklogOptions<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = {
|
|
195
228
|
configUrl: string | URL;
|
|
196
|
-
baseBranch: string;
|
|
197
229
|
implValidateCommand?: ValidationCommandFn<V, SV> | string;
|
|
198
230
|
backlogItemsDir?: string;
|
|
199
|
-
} & Omit<LumpJsConfig<V, SV>, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt' | 'steps'
|
|
200
|
-
declare
|
|
231
|
+
} & Omit<LumpJsConfig<V, SV>, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt' | 'steps'>;
|
|
232
|
+
declare const FEATURE_BACKLOG_WORKFLOWS: readonly ["tdd", "directImpl", "manual"];
|
|
233
|
+
declare function parseFeatureWorkflow(itemName: string, raw: unknown): FeatureBacklogWorkflow | undefined;
|
|
234
|
+
declare function resolveFeatureBacklogItem(input: {
|
|
235
|
+
item: FeatureBacklogItem;
|
|
236
|
+
paths: BacklogPaths;
|
|
237
|
+
projectRoot: string;
|
|
238
|
+
discoveryBranch: string;
|
|
239
|
+
}): Promise<BacklogItemResolution<FeatureBacklogStage>>;
|
|
201
240
|
declare const featureBacklog: <V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(options: FeatureBacklogOptions<V, SV>) => LumpJsConfig<V, SV>;
|
|
202
241
|
|
|
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 };
|
|
242
|
+
export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, FEATURE_BACKLOG_WORKFLOWS, OPEN_PR_PROVIDERS, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, listTodoRelativeDirs, lumpPathAndName, normalizeMaybePromGetter, openPrPostTeardown, parseFeatureWorkflow, projectRootFromConfigUrl, requireArtifactStep, resolveBacklogPaths, resolveFeatureBacklogItem, resolveImplValidateCommand, retryUntilGreen, setTaskDoneStep, shellCommand, validateBaseBacklogItem, ymlBacklogContexts };
|
|
243
|
+
export type { AbstractionBacklogOptions, AbstractionFinderOptions, AbstractionFinderScanCommand, BacklogItemResolution, BacklogOptions, BacklogPaths, BacklogStageDefinition, BaseBacklogItem, DoneBacklogItem, EphemeralContextListFnOptions, FeatureBacklogContextVariables, FeatureBacklogItem, FeatureBacklogOptions, FeatureBacklogRunnableWorkflow, FeatureBacklogStage, FeatureBacklogWorkflow, FolderBacklogContextsOptions, GetFirstStepsInput, GetRecursiveStepsOptions, ImplValidateCommand, IsValidationCommandResultOkInput, MaybePromGetter, OpenPrPostTeardownOptions, OpenPrProvider, Recipe, RetryUntilGreenInput, StepIndex, ValidationCommandFn, ValidationCommandFnInput, YmlBacklogContextsOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { normalizeSteps, readYamlList, defineConfig, getContextStatus } from '@lumpcode/cli-utils';
|
|
2
|
-
import { shellSingleQuote, pathExists } from '@lumpcode/core';
|
|
1
|
+
import { getGitCommitMessage, normalizeSteps, readYamlList, defineConfig, getContextStatus } from '@lumpcode/cli-utils';
|
|
2
|
+
import { execBinary, shellSingleQuote, pathExists } from '@lumpcode/core';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
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
8
|
import { load, dump } from 'js-yaml';
|
|
9
|
+
import fs$1 from 'node:fs/promises';
|
|
9
10
|
|
|
10
11
|
/** Run a shell script via `sh -c` (portable on Unix-like systems and Git Bash on Windows). */
|
|
11
12
|
function shellCommand(script) {
|
|
@@ -15,6 +16,111 @@ function shellCommand(script) {
|
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const LOG_PREFIX = '[lumpcode/recipes]';
|
|
20
|
+
async function openGithubPr(input) {
|
|
21
|
+
const { workspacePath, baseBranch, branchName, title, body } = input;
|
|
22
|
+
const cwd = workspacePath;
|
|
23
|
+
const listed = await execBinary({
|
|
24
|
+
binaryPath: 'gh',
|
|
25
|
+
args: ['pr', 'list', '--head', branchName, '--base', baseBranch, '--json', 'number'],
|
|
26
|
+
cwd,
|
|
27
|
+
});
|
|
28
|
+
if (listed.success && githubPrListHasItems(listed.data.stdout)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const created = await execBinary({
|
|
32
|
+
binaryPath: 'gh',
|
|
33
|
+
args: [
|
|
34
|
+
'pr',
|
|
35
|
+
'create',
|
|
36
|
+
'--base',
|
|
37
|
+
baseBranch,
|
|
38
|
+
'--head',
|
|
39
|
+
branchName,
|
|
40
|
+
'--title',
|
|
41
|
+
title,
|
|
42
|
+
'--body',
|
|
43
|
+
body,
|
|
44
|
+
],
|
|
45
|
+
cwd,
|
|
46
|
+
});
|
|
47
|
+
if (!created.success) {
|
|
48
|
+
console.error(`${LOG_PREFIX} gh pr create failed: ${created.data.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function githubPrListHasItems(stdout) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(stdout);
|
|
54
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const LUMP_BRANCH_PREFIX = 'lump/';
|
|
62
|
+
const OPEN_PR_PROVIDERS = ['github'];
|
|
63
|
+
function openPrPostTeardown(options) {
|
|
64
|
+
const { provider, lumpName: lumpNameOption, title, body } = options;
|
|
65
|
+
return async (input) => {
|
|
66
|
+
const { baseBranch, branchName, contextList, workspacePath } = input;
|
|
67
|
+
if (!branchName || branchName === baseBranch) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const remote = await execBinary({
|
|
71
|
+
binaryPath: 'git',
|
|
72
|
+
args: ['ls-remote', '--heads', 'origin', branchName],
|
|
73
|
+
cwd: workspacePath,
|
|
74
|
+
});
|
|
75
|
+
if (!remote.success || !remote.data.stdout.trim()) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const names = contextList.map((ctx) => ctx.name).filter((name) => name.length > 0);
|
|
79
|
+
const label = names.length > 0 ? names.join(', ') : branchName;
|
|
80
|
+
const lumpName = lumpNameOption ?? lumpNameFromBranch(branchName);
|
|
81
|
+
const resolvedTitle = title?.(input) ?? defaultPrTitle({ lumpName, label });
|
|
82
|
+
const resolvedBody = body?.(input) ?? `LUMP contexts: ${label}`;
|
|
83
|
+
await openPrWithProvider({
|
|
84
|
+
provider,
|
|
85
|
+
workspacePath,
|
|
86
|
+
baseBranch,
|
|
87
|
+
branchName,
|
|
88
|
+
title: resolvedTitle,
|
|
89
|
+
body: resolvedBody,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function defaultPrTitle(input) {
|
|
94
|
+
const { lumpName, label } = input;
|
|
95
|
+
if (lumpName) {
|
|
96
|
+
return getGitCommitMessage({ lumpName, contextName: label });
|
|
97
|
+
}
|
|
98
|
+
return `LUMP: ${label}`;
|
|
99
|
+
}
|
|
100
|
+
function lumpNameFromBranch(branchName) {
|
|
101
|
+
if (!branchName.startsWith(LUMP_BRANCH_PREFIX)) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const rest = branchName.slice(LUMP_BRANCH_PREFIX.length);
|
|
105
|
+
const slash = rest.indexOf('/');
|
|
106
|
+
if (slash <= 0) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return rest.slice(0, slash);
|
|
110
|
+
}
|
|
111
|
+
async function openPrWithProvider(input) {
|
|
112
|
+
const { provider, ...prInput } = input;
|
|
113
|
+
switch (provider) {
|
|
114
|
+
case 'github':
|
|
115
|
+
await openGithubPr(prInput);
|
|
116
|
+
return;
|
|
117
|
+
default: {
|
|
118
|
+
const _exhaustive = provider;
|
|
119
|
+
throw new Error(`Unhandled PR provider: ${_exhaustive}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
18
124
|
function normalizeMaybePromGetter(maybePromGetter, defaultValue) {
|
|
19
125
|
if (typeof maybePromGetter === 'function') {
|
|
20
126
|
return maybePromGetter;
|
|
@@ -284,10 +390,26 @@ async function listTodoFolderNames(todoDir) {
|
|
|
284
390
|
throw error;
|
|
285
391
|
}
|
|
286
392
|
}
|
|
393
|
+
/** Path relative to `todo/`, using `/` so it is stable in context variables. */
|
|
394
|
+
async function listTodoRelativeDirs(todoDir) {
|
|
395
|
+
const topNames = await listTodoFolderNames(todoDir);
|
|
396
|
+
const relativeDirs = [];
|
|
397
|
+
for (const name of topNames) {
|
|
398
|
+
const ticketNames = await listTodoFolderNames(path$1.join(todoDir, name, 'tickets'));
|
|
399
|
+
if (ticketNames.length === 0) {
|
|
400
|
+
relativeDirs.push(name);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
for (const ticketName of ticketNames) {
|
|
404
|
+
relativeDirs.push(`${name}/tickets/${ticketName}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return relativeDirs;
|
|
408
|
+
}
|
|
287
409
|
function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
288
410
|
return async () => {
|
|
289
411
|
const todoDir = path$1.join(backlogItemsDir, 'todo');
|
|
290
|
-
const folderNames = await
|
|
412
|
+
const folderNames = await listTodoRelativeDirs(todoDir);
|
|
291
413
|
const discovered = await Promise.all(folderNames.map(async (folderName) => {
|
|
292
414
|
const descPath = path$1.join(todoDir, folderName, 'desc.yml');
|
|
293
415
|
let rawText;
|
|
@@ -302,13 +424,22 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
|
302
424
|
throw error;
|
|
303
425
|
}
|
|
304
426
|
const raw = load(rawText);
|
|
427
|
+
const itemFolderName = path$1.basename(folderName);
|
|
305
428
|
const baseItem = validateBaseBacklogItem(raw, `in folder "${folderName}"`);
|
|
306
|
-
if (baseItem.name !==
|
|
429
|
+
if (baseItem.name !== itemFolderName) {
|
|
307
430
|
throw new Error(`Backlog item folder "${folderName}" desc.yml name "${baseItem.name}" must match folder name`);
|
|
308
431
|
}
|
|
309
432
|
const item = parseItem ? parseItem(baseItem, folderName, raw) : baseItem;
|
|
310
433
|
return { item, folderName };
|
|
311
434
|
}));
|
|
435
|
+
const seenNames = new Map();
|
|
436
|
+
for (const { item, folderName } of discovered) {
|
|
437
|
+
const previous = seenNames.get(item.name);
|
|
438
|
+
if (previous !== undefined) {
|
|
439
|
+
throw new Error(`Duplicate backlog item name "${item.name}" in folders "${previous}" and "${folderName}"`);
|
|
440
|
+
}
|
|
441
|
+
seenNames.set(item.name, folderName);
|
|
442
|
+
}
|
|
312
443
|
discovered.sort((a, b) => {
|
|
313
444
|
if (a.item.priority !== b.item.priority) {
|
|
314
445
|
return a.item.priority - b.item.priority;
|
|
@@ -339,7 +470,7 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
|
|
|
339
470
|
function isPlainObject(value) {
|
|
340
471
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
341
472
|
}
|
|
342
|
-
/** Moves a finished backlog item folder from todo/ to
|
|
473
|
+
/** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
|
|
343
474
|
function folderSetTaskDoneStep(input) {
|
|
344
475
|
const nameVarName = input.nameVarName ?? 'TASK_NAME';
|
|
345
476
|
return {
|
|
@@ -347,19 +478,29 @@ function folderSetTaskDoneStep(input) {
|
|
|
347
478
|
const variables = context.variables;
|
|
348
479
|
const itemsDirRelative = variables[input.itemsDirVarName];
|
|
349
480
|
const taskName = variables[nameVarName];
|
|
350
|
-
|
|
481
|
+
const itemDirRelative = variables.BACKLOG_ITEM_DIR;
|
|
482
|
+
if (!itemsDirRelative || (!itemDirRelative && !taskName)) {
|
|
351
483
|
throw new Error('Backlog items directory and task name are required');
|
|
352
484
|
}
|
|
353
485
|
const itemsDir = path$1.join(workspacePath, itemsDirRelative);
|
|
354
|
-
const
|
|
355
|
-
const
|
|
486
|
+
const todoDir = path$1.join(itemsDir, 'todo');
|
|
487
|
+
const fromDir = itemDirRelative
|
|
488
|
+
? path$1.join(workspacePath, itemDirRelative)
|
|
489
|
+
: path$1.join(todoDir, taskName);
|
|
490
|
+
const relativeFromTodo = path$1.relative(todoDir, fromDir);
|
|
491
|
+
if (relativeFromTodo === '' ||
|
|
492
|
+
relativeFromTodo.startsWith('..') ||
|
|
493
|
+
path$1.isAbsolute(relativeFromTodo)) {
|
|
494
|
+
throw new Error(`BACKLOG_ITEM_DIR must be a folder under ${todoDir}: ${itemDirRelative ?? fromDir}`);
|
|
495
|
+
}
|
|
496
|
+
const toDir = path$1.join(itemsDir, 'completed', relativeFromTodo);
|
|
356
497
|
const descPath = path$1.join(fromDir, 'desc.yml');
|
|
357
498
|
const completedDescPath = path$1.join(toDir, 'desc.yml');
|
|
358
499
|
if (!(await pathExists(fromDir))) {
|
|
359
500
|
return null;
|
|
360
501
|
}
|
|
361
502
|
if (await pathExists(toDir)) {
|
|
362
|
-
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName}": already exists at ${toDir}`);
|
|
503
|
+
console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
|
|
363
504
|
return null;
|
|
364
505
|
}
|
|
365
506
|
const rawText = await fs.readFile(descPath, 'utf-8');
|
|
@@ -371,7 +512,7 @@ function folderSetTaskDoneStep(input) {
|
|
|
371
512
|
...raw,
|
|
372
513
|
completedAt: new Date().toISOString(),
|
|
373
514
|
};
|
|
374
|
-
await fs.mkdir(path$1.
|
|
515
|
+
await fs.mkdir(path$1.dirname(toDir), { recursive: true });
|
|
375
516
|
await fs.rename(fromDir, toDir);
|
|
376
517
|
await fs.writeFile(completedDescPath, dump(updated));
|
|
377
518
|
return {
|
|
@@ -620,29 +761,72 @@ const abstractionBacklog = defineRecipe(function abstractionBacklog(options) {
|
|
|
620
761
|
});
|
|
621
762
|
});
|
|
622
763
|
|
|
764
|
+
function scanCommandStep(scanCommand) {
|
|
765
|
+
if (typeof scanCommand === 'function') {
|
|
766
|
+
return { commandFn: scanCommand };
|
|
767
|
+
}
|
|
768
|
+
if (typeof scanCommand === 'string') {
|
|
769
|
+
return { commandFn: () => shellCommand(scanCommand) };
|
|
770
|
+
}
|
|
771
|
+
return { commandFn: () => scanCommand };
|
|
772
|
+
}
|
|
623
773
|
const abstractionFinder = defineRecipe(function abstractionFinder(options) {
|
|
624
|
-
const { maxPendingAbstractions = 5, scanDirectories, customPrompt, backlogItemsDir,
|
|
774
|
+
const { configUrl, maxPendingAbstractions = 5, scanDirectories, utilDir, customPrompt, backlogItemsDir, scanCommand, steps: stepsOverride, ...rest } = options;
|
|
775
|
+
if (path$1.isAbsolute(backlogItemsDir)) {
|
|
776
|
+
throw new Error(`backlogItemsDir must be project-root-relative, not absolute: ${backlogItemsDir}`);
|
|
777
|
+
}
|
|
778
|
+
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
779
|
+
const todoDir = path$1.join(projectRoot, backlogItemsDir, 'todo');
|
|
780
|
+
const agentSteps = stepsOverride ?? buildFinderPrompt({
|
|
781
|
+
backlogItemsDir,
|
|
782
|
+
scanDirectories,
|
|
783
|
+
utilDir,
|
|
784
|
+
customPrompt,
|
|
785
|
+
});
|
|
786
|
+
const steps = [
|
|
787
|
+
...(scanCommand === undefined ? [] : [scanCommandStep(scanCommand)]),
|
|
788
|
+
...normalizeSteps({
|
|
789
|
+
prompt: undefined,
|
|
790
|
+
jsSteps: agentSteps,
|
|
791
|
+
}),
|
|
792
|
+
];
|
|
625
793
|
return defineConfig({
|
|
626
|
-
...
|
|
794
|
+
...rest,
|
|
627
795
|
getContextListFn: ephemeralContextListFn({
|
|
628
|
-
contextCount
|
|
796
|
+
async contextCount() {
|
|
797
|
+
const pending = (await listTodoRelativeDirs(todoDir)).length;
|
|
798
|
+
return pending >= maxPendingAbstractions ? 0 : 1;
|
|
799
|
+
},
|
|
629
800
|
variables: {
|
|
630
801
|
BACKLOG_ITEMS_DIR: backlogItemsDir,
|
|
631
802
|
},
|
|
632
803
|
}),
|
|
633
|
-
steps
|
|
804
|
+
steps,
|
|
634
805
|
});
|
|
635
806
|
});
|
|
636
|
-
function buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt, }) {
|
|
637
|
-
|
|
638
|
-
|
|
807
|
+
function buildFinderPrompt({ backlogItemsDir, scanDirectories, utilDir, customPrompt, }) {
|
|
808
|
+
if (customPrompt) {
|
|
809
|
+
return customPrompt();
|
|
810
|
+
}
|
|
811
|
+
const scanLabel = scanDirectories && scanDirectories.length > 0
|
|
812
|
+
? scanDirectories.map((dir) => `@${dir}`).join(' and ')
|
|
813
|
+
: 'the codebase';
|
|
814
|
+
const materializeDir = utilDir ?? scanDirectories?.[0];
|
|
815
|
+
const materializeLine = materializeDir
|
|
816
|
+
? `- Would materialize as a new util under @${materializeDir}/<utilName>/, following existing conventions there.`
|
|
817
|
+
: '- Would materialize as a new util in the scanned tree, following existing conventions there.';
|
|
818
|
+
const refactorScope = scanDirectories && scanDirectories.length > 0
|
|
819
|
+
? scanDirectories.join(' and ')
|
|
820
|
+
: 'the scanned tree';
|
|
821
|
+
return `
|
|
822
|
+
Scan ${scanLabel} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
|
|
639
823
|
|
|
640
824
|
List existing backlog item names under @${backlogItemsDir}/todo/ and @${backlogItemsDir}/completed/. Do not propose abstractions whose util name already appears in either directory.
|
|
641
825
|
|
|
642
826
|
Pick exactly one new abstraction that:
|
|
643
827
|
- Has a clear util name matching ^[a-zA-Z0-9_-]+$ that describes the pattern it captures.
|
|
644
|
-
|
|
645
|
-
- Would shrink the codebase: refactoring all call sites in
|
|
828
|
+
${materializeLine}
|
|
829
|
+
- Would shrink the codebase: refactoring all call sites in ${refactorScope} should reduce net line count (excluding new unit tests).
|
|
646
830
|
|
|
647
831
|
Create exactly one new backlog item folder at @${backlogItemsDir}/todo/<utilName>/ with:
|
|
648
832
|
- desc.yml containing:
|
|
@@ -662,6 +846,11 @@ Do not take too much time looking for every possible abstraction. Once you found
|
|
|
662
846
|
`.trim();
|
|
663
847
|
}
|
|
664
848
|
|
|
849
|
+
const FEATURE_BACKLOG_WORKFLOWS = [
|
|
850
|
+
'tdd',
|
|
851
|
+
'directImpl',
|
|
852
|
+
'manual',
|
|
853
|
+
];
|
|
665
854
|
const RESERVED_NAME_SUFFIXES = ['_req', '_testPlan', '_tests_impl'];
|
|
666
855
|
function assertValidFeatureItemName(name) {
|
|
667
856
|
for (const suffix of RESERVED_NAME_SUFFIXES) {
|
|
@@ -670,6 +859,9 @@ function assertValidFeatureItemName(name) {
|
|
|
670
859
|
}
|
|
671
860
|
}
|
|
672
861
|
}
|
|
862
|
+
function featureItemContextBaseName(item) {
|
|
863
|
+
return item.parentName ? `${item.parentName}-${item.name}` : item.name;
|
|
864
|
+
}
|
|
673
865
|
function featureContextName(itemName, stage) {
|
|
674
866
|
switch (stage) {
|
|
675
867
|
case 'makeReq':
|
|
@@ -679,6 +871,7 @@ function featureContextName(itemName, stage) {
|
|
|
679
871
|
case 'testImpl':
|
|
680
872
|
return `${itemName}_tests_impl`;
|
|
681
873
|
case 'implementation':
|
|
874
|
+
case 'directImpl':
|
|
682
875
|
return itemName;
|
|
683
876
|
default: {
|
|
684
877
|
const _exhaustive = stage;
|
|
@@ -686,9 +879,84 @@ function featureContextName(itemName, stage) {
|
|
|
686
879
|
}
|
|
687
880
|
}
|
|
688
881
|
}
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
|
|
882
|
+
function parentNameFromTodoRelativeDir(todoRelativeDir) {
|
|
883
|
+
const parts = todoRelativeDir.split('/');
|
|
884
|
+
if (parts.length === 3 && parts[1] === 'tickets') {
|
|
885
|
+
return parts[0];
|
|
886
|
+
}
|
|
887
|
+
return undefined;
|
|
888
|
+
}
|
|
889
|
+
function parseFeatureWorkflow(itemName, raw) {
|
|
890
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
const record = raw;
|
|
894
|
+
if (record.workflow === undefined) {
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
if (typeof record.workflow !== 'string' ||
|
|
898
|
+
!FEATURE_BACKLOG_WORKFLOWS.includes(record.workflow)) {
|
|
899
|
+
throw new Error(`Backlog item "${itemName}" field "workflow" must be one of: ${FEATURE_BACKLOG_WORKFLOWS.join(', ')}`);
|
|
900
|
+
}
|
|
901
|
+
return record.workflow;
|
|
902
|
+
}
|
|
903
|
+
async function resolveItemWorkflow(input) {
|
|
904
|
+
const { item, paths, projectRoot } = input;
|
|
905
|
+
if (item.workflow !== undefined) {
|
|
906
|
+
return item.workflow;
|
|
907
|
+
}
|
|
908
|
+
if (item.parentName === undefined) {
|
|
909
|
+
return 'tdd';
|
|
910
|
+
}
|
|
911
|
+
const parentDescPath = path$1.join(projectRoot, paths.backlogItemsDir, 'todo', item.parentName, 'desc.yml');
|
|
912
|
+
let rawText;
|
|
913
|
+
try {
|
|
914
|
+
rawText = await fs$1.readFile(parentDescPath, 'utf-8');
|
|
915
|
+
}
|
|
916
|
+
catch (error) {
|
|
917
|
+
const err = error;
|
|
918
|
+
if (err.code === 'ENOENT') {
|
|
919
|
+
return 'tdd';
|
|
920
|
+
}
|
|
921
|
+
throw error;
|
|
922
|
+
}
|
|
923
|
+
return parseFeatureWorkflow(item.parentName, load(rawText)) ?? 'tdd';
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* `dev` → only top-level `directImpl` (tickets never run on `dev`, even if `directImpl`).
|
|
927
|
+
* `feature/<key>` → exact item name, or the parent todo name for tickets.
|
|
928
|
+
* `manual` never reaches here (`resolveFeatureBacklogItem` ignores it first).
|
|
929
|
+
*/
|
|
930
|
+
function itemMatchesDiscoveryBranch(input) {
|
|
931
|
+
const { itemName, parentName, discoveryBranch, workflow } = input;
|
|
932
|
+
if (discoveryBranch === 'dev') {
|
|
933
|
+
return workflow === 'directImpl' && parentName === undefined;
|
|
934
|
+
}
|
|
935
|
+
if (!discoveryBranch.startsWith('feature/')) {
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
const key = discoveryBranch.slice('feature/'.length);
|
|
939
|
+
return (parentName ?? itemName) === key;
|
|
940
|
+
}
|
|
941
|
+
async function resolveFeatureBacklogItem(input) {
|
|
942
|
+
const { item, paths, projectRoot, discoveryBranch } = input;
|
|
943
|
+
const contextBaseName = featureItemContextBaseName(item);
|
|
944
|
+
const workflow = await resolveItemWorkflow({ item, paths, projectRoot });
|
|
945
|
+
if (workflow === 'manual') {
|
|
946
|
+
return { ignored: true };
|
|
947
|
+
}
|
|
948
|
+
if (!!item.completedAt ||
|
|
949
|
+
!itemMatchesDiscoveryBranch({
|
|
950
|
+
itemName: item.name,
|
|
951
|
+
parentName: item.parentName,
|
|
952
|
+
discoveryBranch,
|
|
953
|
+
workflow,
|
|
954
|
+
})) {
|
|
955
|
+
return { ignored: true };
|
|
956
|
+
}
|
|
957
|
+
const itemDir = path$1.join(paths.backlogItemsDir, 'todo', item.todoRelativeDir);
|
|
958
|
+
const reqFilePath = path$1.join(itemDir, 'requirements.md');
|
|
959
|
+
const testPlanFilePath = path$1.join(itemDir, 'testPlan.md');
|
|
692
960
|
const hasReq = await pathExists(path$1.join(projectRoot, reqFilePath));
|
|
693
961
|
if (!hasReq) {
|
|
694
962
|
if (item.manualReq === true) {
|
|
@@ -696,7 +964,14 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
696
964
|
}
|
|
697
965
|
return {
|
|
698
966
|
stage: 'makeReq',
|
|
699
|
-
contextName: featureContextName(
|
|
967
|
+
contextName: featureContextName(contextBaseName, 'makeReq'),
|
|
968
|
+
variables: { REQ_FILE: reqFilePath },
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
if (workflow === 'directImpl') {
|
|
972
|
+
return {
|
|
973
|
+
stage: 'directImpl',
|
|
974
|
+
contextName: featureContextName(contextBaseName, 'directImpl'),
|
|
700
975
|
variables: { REQ_FILE: reqFilePath },
|
|
701
976
|
};
|
|
702
977
|
}
|
|
@@ -704,24 +979,24 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
704
979
|
if (!hasTestPlan) {
|
|
705
980
|
return {
|
|
706
981
|
stage: 'makeTestPlan',
|
|
707
|
-
contextName: featureContextName(
|
|
982
|
+
contextName: featureContextName(contextBaseName, 'makeTestPlan'),
|
|
708
983
|
variables: {
|
|
709
984
|
REQ_FILE: reqFilePath,
|
|
710
985
|
TEST_PLAN_FILE: testPlanFilePath,
|
|
711
986
|
},
|
|
712
987
|
};
|
|
713
988
|
}
|
|
714
|
-
const testsImplContextName = featureContextName(
|
|
989
|
+
const testsImplContextName = featureContextName(contextBaseName, 'testImpl');
|
|
715
990
|
const testsImplStatus = await getContextStatus({
|
|
716
991
|
projectRoot,
|
|
717
992
|
contextName: testsImplContextName,
|
|
718
|
-
lumpName,
|
|
719
|
-
baseBranch,
|
|
993
|
+
lumpName: paths.lumpName,
|
|
994
|
+
baseBranch: discoveryBranch,
|
|
720
995
|
});
|
|
721
996
|
if (testsImplStatus === 'finished') {
|
|
722
997
|
return {
|
|
723
998
|
stage: 'implementation',
|
|
724
|
-
contextName: featureContextName(
|
|
999
|
+
contextName: featureContextName(contextBaseName, 'implementation'),
|
|
725
1000
|
variables: {
|
|
726
1001
|
REQ_FILE: reqFilePath,
|
|
727
1002
|
TEST_PLAN_FILE: testPlanFilePath,
|
|
@@ -741,27 +1016,38 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
|
|
|
741
1016
|
};
|
|
742
1017
|
}
|
|
743
1018
|
const featureBacklog = defineRecipe(function featureBacklog(options) {
|
|
744
|
-
const { configUrl,
|
|
1019
|
+
const { configUrl, implValidateCommand, backlogItemsDir, ...rest } = options;
|
|
745
1020
|
const projectRoot = projectRootFromConfigUrl(configUrl);
|
|
746
1021
|
const runImplValidation = resolveImplValidateCommand(implValidateCommand ??
|
|
747
1022
|
'echo "No implementation validation command provided. I say, trust but verify, but well..."');
|
|
748
1023
|
return backlog({
|
|
749
1024
|
configUrl,
|
|
750
1025
|
backlogItemsDir,
|
|
751
|
-
|
|
752
|
-
parseItem(baseItem, _folderName, raw) {
|
|
1026
|
+
parseItem(baseItem, folderName, raw) {
|
|
753
1027
|
assertValidFeatureItemName(baseItem.name);
|
|
754
1028
|
const record = raw;
|
|
755
1029
|
if (record.manualReq !== undefined && typeof record.manualReq !== 'boolean') {
|
|
756
1030
|
throw new Error(`Backlog item "${baseItem.name}" field "manualReq" must be a boolean`);
|
|
757
1031
|
}
|
|
1032
|
+
const parentName = parentNameFromTodoRelativeDir(folderName);
|
|
758
1033
|
return {
|
|
759
1034
|
...baseItem,
|
|
1035
|
+
todoRelativeDir: folderName,
|
|
1036
|
+
parentName,
|
|
1037
|
+
dependsOn: parentName
|
|
1038
|
+
? baseItem.dependsOn?.map((dep) => `${parentName}-${dep}`)
|
|
1039
|
+
: baseItem.dependsOn,
|
|
760
1040
|
manualReq: record.manualReq === true ? true : undefined,
|
|
1041
|
+
workflow: parseFeatureWorkflow(baseItem.name, raw),
|
|
761
1042
|
};
|
|
762
1043
|
},
|
|
763
|
-
async resolveItem({ item, paths }) {
|
|
764
|
-
return resolveFeatureBacklogItem(
|
|
1044
|
+
async resolveItem({ item, paths, discoveryBranch }) {
|
|
1045
|
+
return resolveFeatureBacklogItem({
|
|
1046
|
+
item,
|
|
1047
|
+
paths,
|
|
1048
|
+
projectRoot,
|
|
1049
|
+
discoveryBranch,
|
|
1050
|
+
});
|
|
765
1051
|
},
|
|
766
1052
|
stages: {
|
|
767
1053
|
makeReq: {
|
|
@@ -910,9 +1196,28 @@ The implementation should make the tests pass. Do not edit any test file except
|
|
|
910
1196
|
validationCommandFn: runImplValidation,
|
|
911
1197
|
}),
|
|
912
1198
|
},
|
|
1199
|
+
directImpl: {
|
|
1200
|
+
completion: 'moveToDone',
|
|
1201
|
+
steps: retryUntilGreen({
|
|
1202
|
+
steps: [
|
|
1203
|
+
{
|
|
1204
|
+
promptFn({ context: ctx }) {
|
|
1205
|
+
const vars = ctx.variables;
|
|
1206
|
+
const { REQ_FILE } = vars;
|
|
1207
|
+
return `
|
|
1208
|
+
Implement the feature described in @${REQ_FILE}.
|
|
1209
|
+
Add or update tests as needed so the suite covers the change, and make validation pass.
|
|
1210
|
+
Do not edit @${REQ_FILE} unless absolutely necessary.
|
|
1211
|
+
`.trim();
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
],
|
|
1215
|
+
validationCommandFn: runImplValidation,
|
|
1216
|
+
}),
|
|
1217
|
+
},
|
|
913
1218
|
},
|
|
914
1219
|
...rest,
|
|
915
1220
|
});
|
|
916
1221
|
});
|
|
917
1222
|
|
|
918
|
-
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 };
|
|
1223
|
+
export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, FEATURE_BACKLOG_WORKFLOWS, OPEN_PR_PROVIDERS, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, listTodoRelativeDirs, lumpPathAndName, normalizeMaybePromGetter, openPrPostTeardown, parseFeatureWorkflow, projectRootFromConfigUrl, requireArtifactStep, resolveBacklogPaths, resolveFeatureBacklogItem, resolveImplValidateCommand, retryUntilGreen, setTaskDoneStep, shellCommand, validateBaseBacklogItem, ymlBacklogContexts };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumpcode/recipes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Lumpcode lump recipes and kit helpers",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lumpcode",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"test:watch": "vitest watch"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@lumpcode/cli-utils": "^0.
|
|
48
|
-
"@lumpcode/core": "^0.
|
|
47
|
+
"@lumpcode/cli-utils": "^0.3.1",
|
|
48
|
+
"@lumpcode/core": "^0.3.1",
|
|
49
49
|
"js-yaml": "^5.0.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|