@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 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` | Feature items with requirements test plan test implementation implementation |
19
- | **abstractionFinder** | `abstractionFinder` | Ephemeral contexts that scan for duplicated CLI utils and append one backlog item + requirements doc per run |
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 only; optional until makeTestPlan
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 (e.g. `manualReq` for featureBacklog).
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: 5,
109
+ maximumNumberOfConcurrentBranches: 1,
100
110
  verbose: true,
101
111
  keepHistory: true,
102
112
  lumpVariables: { model: 'composer-2.5' },
103
- discoveryBranch: 'dev',
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: ['packages/apps/cli'],
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 listTodoFolderNames(todoDir);
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 !== folderName) {
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 completed/ after the context completes. */
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
- if (!itemsDirRelative || !taskName) {
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 fromDir = path$1.join(itemsDir, 'todo', taskName);
357
- const toDir = path$1.join(itemsDir, 'completed', taskName);
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.join(itemsDir, 'completed'), { recursive: true });
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, } = options; // TODO : check if pending abstcations in backlog are less than maxPendingAbstractions
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
- ...options,
796
+ ...rest,
629
797
  getContextListFn: ephemeralContextListFn({
630
- contextCount: maxPendingAbstractions,
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: buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt }),
806
+ steps,
636
807
  });
637
808
  });
638
- function buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt, }) {
639
- return customPrompt ? customPrompt() : `
640
- Scan ${(scanDirectories || []).map((dir) => `@${dir}`).join(' and ') || 'the codebase'} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
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
- - Would materialize as a new util under packages/apps/cli/src/utils/<utilName>/.
647
- - Would shrink the codebase: refactoring all call sites in packages/apps/cli should reduce net line count (excluding new unit tests).
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
- async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, baseBranch) {
692
- const reqFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'requirements.md');
693
- const testPlanFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'testPlan.md');
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(item.name, 'makeReq'),
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(item.name, 'makeTestPlan'),
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(item.name, 'testImpl');
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(item.name, 'implementation'),
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, baseBranch, implValidateCommand, backlogItemsDir, ...rest } = options;
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
- baseBranch,
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(item, paths, projectRoot, paths.lumpName, baseBranch);
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 completed/ after the context completes. */
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
- } & LumpJsConfig<V, SV>;
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' | 'baseBranch'>;
200
- declare function resolveFeatureBacklogItem(item: FeatureBacklogItem, paths: BacklogPaths, projectRoot: string, lumpName: string, baseBranch: string): Promise<BacklogItemResolution<FeatureBacklogStage>>;
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 listTodoFolderNames(todoDir);
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 !== folderName) {
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 completed/ after the context completes. */
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
- if (!itemsDirRelative || !taskName) {
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 fromDir = path$1.join(itemsDir, 'todo', taskName);
355
- const toDir = path$1.join(itemsDir, 'completed', taskName);
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.join(itemsDir, 'completed'), { recursive: true });
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, } = options; // TODO : check if pending abstcations in backlog are less than maxPendingAbstractions
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
- ...options,
794
+ ...rest,
627
795
  getContextListFn: ephemeralContextListFn({
628
- contextCount: maxPendingAbstractions,
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: buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt }),
804
+ steps,
634
805
  });
635
806
  });
636
- function buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt, }) {
637
- return customPrompt ? customPrompt() : `
638
- Scan ${(scanDirectories || []).map((dir) => `@${dir}`).join(' and ') || 'the codebase'} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
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
- - Would materialize as a new util under packages/apps/cli/src/utils/<utilName>/.
645
- - Would shrink the codebase: refactoring all call sites in packages/apps/cli should reduce net line count (excluding new unit tests).
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
- async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, baseBranch) {
690
- const reqFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'requirements.md');
691
- const testPlanFilePath = path$1.join(paths.backlogItemsDir, 'todo', item.name, 'testPlan.md');
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(item.name, 'makeReq'),
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(item.name, 'makeTestPlan'),
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(item.name, 'testImpl');
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(item.name, 'implementation'),
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, baseBranch, implValidateCommand, backlogItemsDir, ...rest } = options;
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
- baseBranch,
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(item, paths, projectRoot, paths.lumpName, baseBranch);
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.2.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.2.0",
48
- "@lumpcode/core": "^0.2.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": {