@lumpcode/recipes 0.2.0 → 0.3.0

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,7 +46,7 @@ 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`
@@ -92,15 +93,14 @@ export default featureBacklog<
92
93
  CursorPresetLumpVariables,
93
94
  CursorPresetStepVariables
94
95
  >({
95
- baseBranch: 'dev',
96
96
  command: 'cursor',
97
97
  configUrl: import.meta.url,
98
98
  registerCommands: ['cursor'],
99
- maximumNumberOfConcurrentBranches: 5,
99
+ maximumNumberOfConcurrentBranches: 1,
100
100
  verbose: true,
101
101
  keepHistory: true,
102
102
  lumpVariables: { model: 'composer-2.5' },
103
- discoveryBranch: 'dev',
103
+ discoveryBranches: ['dev', 'feature/*'],
104
104
  implValidateCommand: [
105
105
  'npm run build -w=@lumpcode/cli',
106
106
  'npm run test -w=@lumpcode/cli',
@@ -108,6 +108,8 @@ export default featureBacklog<
108
108
  });
109
109
  ```
110
110
 
111
+ `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`.
112
+
111
113
  ### abstractionFinder + abstractionBacklog
112
114
 
113
115
  Two-lump pipeline: finder tops up the implementer backlog; implementer runs items that already have requirements documents.
@@ -124,15 +126,19 @@ export default abstractionFinder<
124
126
  CursorPresetLumpVariables,
125
127
  CursorPresetStepVariables
126
128
  >({
129
+ configUrl: import.meta.url,
127
130
  maxPendingAbstractions: 5,
128
- scanDirectories: ['packages/apps/cli'],
131
+ scanDirectories: ['src'],
129
132
  backlogItemsDir: '.lumpcode/lumps/abstractionImplementer/backlogItems',
130
133
  command: 'cursor',
131
134
  lumpVariables: { model: 'composer-2.5' },
132
135
  discoveryBranch: 'dev',
136
+ scanCommand: 'npx fallow dupes --mode semantic --format json > .lumpcode/dupes.json',
133
137
  });
134
138
  ```
135
139
 
140
+ `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.
141
+
136
142
  ```ts
137
143
  // .lumpcode/lumps/abstractionImplementer/config.ts
138
144
  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) {
@@ -286,10 +287,26 @@ async function listTodoFolderNames(todoDir) {
286
287
  throw error;
287
288
  }
288
289
  }
290
+ /** Path relative to `todo/`, using `/` so it is stable in context variables. */
291
+ async function listTodoRelativeDirs(todoDir) {
292
+ const topNames = await listTodoFolderNames(todoDir);
293
+ const relativeDirs = [];
294
+ for (const name of topNames) {
295
+ const ticketNames = await listTodoFolderNames(path$1.join(todoDir, name, 'tickets'));
296
+ if (ticketNames.length === 0) {
297
+ relativeDirs.push(name);
298
+ continue;
299
+ }
300
+ for (const ticketName of ticketNames) {
301
+ relativeDirs.push(`${name}/tickets/${ticketName}`);
302
+ }
303
+ }
304
+ return relativeDirs;
305
+ }
289
306
  function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
290
307
  return async () => {
291
308
  const todoDir = path$1.join(backlogItemsDir, 'todo');
292
- const folderNames = await listTodoFolderNames(todoDir);
309
+ const folderNames = await listTodoRelativeDirs(todoDir);
293
310
  const discovered = await Promise.all(folderNames.map(async (folderName) => {
294
311
  const descPath = path$1.join(todoDir, folderName, 'desc.yml');
295
312
  let rawText;
@@ -304,13 +321,22 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
304
321
  throw error;
305
322
  }
306
323
  const raw = jsYaml.load(rawText);
324
+ const itemFolderName = path$1.basename(folderName);
307
325
  const baseItem = validateBaseBacklogItem(raw, `in folder "${folderName}"`);
308
- if (baseItem.name !== folderName) {
326
+ if (baseItem.name !== itemFolderName) {
309
327
  throw new Error(`Backlog item folder "${folderName}" desc.yml name "${baseItem.name}" must match folder name`);
310
328
  }
311
329
  const item = parseItem ? parseItem(baseItem, folderName, raw) : baseItem;
312
330
  return { item, folderName };
313
331
  }));
332
+ const seenNames = new Map();
333
+ for (const { item, folderName } of discovered) {
334
+ const previous = seenNames.get(item.name);
335
+ if (previous !== undefined) {
336
+ throw new Error(`Duplicate backlog item name "${item.name}" in folders "${previous}" and "${folderName}"`);
337
+ }
338
+ seenNames.set(item.name, folderName);
339
+ }
314
340
  discovered.sort((a, b) => {
315
341
  if (a.item.priority !== b.item.priority) {
316
342
  return a.item.priority - b.item.priority;
@@ -341,7 +367,7 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
341
367
  function isPlainObject(value) {
342
368
  return typeof value === 'object' && value !== null && !Array.isArray(value);
343
369
  }
344
- /** Moves a finished backlog item folder from todo/ to completed/ after the context completes. */
370
+ /** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
345
371
  function folderSetTaskDoneStep(input) {
346
372
  const nameVarName = input.nameVarName ?? 'TASK_NAME';
347
373
  return {
@@ -349,19 +375,29 @@ function folderSetTaskDoneStep(input) {
349
375
  const variables = context.variables;
350
376
  const itemsDirRelative = variables[input.itemsDirVarName];
351
377
  const taskName = variables[nameVarName];
352
- if (!itemsDirRelative || !taskName) {
378
+ const itemDirRelative = variables.BACKLOG_ITEM_DIR;
379
+ if (!itemsDirRelative || (!itemDirRelative && !taskName)) {
353
380
  throw new Error('Backlog items directory and task name are required');
354
381
  }
355
382
  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);
383
+ const todoDir = path$1.join(itemsDir, 'todo');
384
+ const fromDir = itemDirRelative
385
+ ? path$1.join(workspacePath, itemDirRelative)
386
+ : path$1.join(todoDir, taskName);
387
+ const relativeFromTodo = path$1.relative(todoDir, fromDir);
388
+ if (relativeFromTodo === '' ||
389
+ relativeFromTodo.startsWith('..') ||
390
+ path$1.isAbsolute(relativeFromTodo)) {
391
+ throw new Error(`BACKLOG_ITEM_DIR must be a folder under ${todoDir}: ${itemDirRelative ?? fromDir}`);
392
+ }
393
+ const toDir = path$1.join(itemsDir, 'completed', relativeFromTodo);
358
394
  const descPath = path$1.join(fromDir, 'desc.yml');
359
395
  const completedDescPath = path$1.join(toDir, 'desc.yml');
360
396
  if (!(await core.pathExists(fromDir))) {
361
397
  return null;
362
398
  }
363
399
  if (await core.pathExists(toDir)) {
364
- console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName}": already exists at ${toDir}`);
400
+ console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
365
401
  return null;
366
402
  }
367
403
  const rawText = await fs.readFile(descPath, 'utf-8');
@@ -373,7 +409,7 @@ function folderSetTaskDoneStep(input) {
373
409
  ...raw,
374
410
  completedAt: new Date().toISOString(),
375
411
  };
376
- await fs.mkdir(path$1.join(itemsDir, 'completed'), { recursive: true });
412
+ await fs.mkdir(path$1.dirname(toDir), { recursive: true });
377
413
  await fs.rename(fromDir, toDir);
378
414
  await fs.writeFile(completedDescPath, jsYaml.dump(updated));
379
415
  return {
@@ -622,29 +658,72 @@ const abstractionBacklog = defineRecipe(function abstractionBacklog(options) {
622
658
  });
623
659
  });
624
660
 
661
+ function scanCommandStep(scanCommand) {
662
+ if (typeof scanCommand === 'function') {
663
+ return { commandFn: scanCommand };
664
+ }
665
+ if (typeof scanCommand === 'string') {
666
+ return { commandFn: () => shellCommand(scanCommand) };
667
+ }
668
+ return { commandFn: () => scanCommand };
669
+ }
625
670
  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
671
+ const { configUrl, maxPendingAbstractions = 5, scanDirectories, utilDir, customPrompt, backlogItemsDir, scanCommand, steps: stepsOverride, ...rest } = options;
672
+ if (path$1.isAbsolute(backlogItemsDir)) {
673
+ throw new Error(`backlogItemsDir must be project-root-relative, not absolute: ${backlogItemsDir}`);
674
+ }
675
+ const projectRoot = projectRootFromConfigUrl(configUrl);
676
+ const todoDir = path$1.join(projectRoot, backlogItemsDir, 'todo');
677
+ const agentSteps = stepsOverride ?? buildFinderPrompt({
678
+ backlogItemsDir,
679
+ scanDirectories,
680
+ utilDir,
681
+ customPrompt,
682
+ });
683
+ const steps = [
684
+ ...(scanCommand === undefined ? [] : [scanCommandStep(scanCommand)]),
685
+ ...cliUtils.normalizeSteps({
686
+ prompt: undefined,
687
+ jsSteps: agentSteps,
688
+ }),
689
+ ];
627
690
  return cliUtils.defineConfig({
628
- ...options,
691
+ ...rest,
629
692
  getContextListFn: ephemeralContextListFn({
630
- contextCount: maxPendingAbstractions,
693
+ async contextCount() {
694
+ const pending = (await listTodoRelativeDirs(todoDir)).length;
695
+ return pending >= maxPendingAbstractions ? 0 : 1;
696
+ },
631
697
  variables: {
632
698
  BACKLOG_ITEMS_DIR: backlogItemsDir,
633
699
  },
634
700
  }),
635
- steps: buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt }),
701
+ steps,
636
702
  });
637
703
  });
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).
704
+ function buildFinderPrompt({ backlogItemsDir, scanDirectories, utilDir, customPrompt, }) {
705
+ if (customPrompt) {
706
+ return customPrompt();
707
+ }
708
+ const scanLabel = scanDirectories && scanDirectories.length > 0
709
+ ? scanDirectories.map((dir) => `@${dir}`).join(' and ')
710
+ : 'the codebase';
711
+ const materializeDir = utilDir ?? scanDirectories?.[0];
712
+ const materializeLine = materializeDir
713
+ ? `- Would materialize as a new util under @${materializeDir}/<utilName>/, following existing conventions there.`
714
+ : '- Would materialize as a new util in the scanned tree, following existing conventions there.';
715
+ const refactorScope = scanDirectories && scanDirectories.length > 0
716
+ ? scanDirectories.join(' and ')
717
+ : 'the scanned tree';
718
+ return `
719
+ Scan ${scanLabel} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
641
720
 
642
721
  List existing backlog item names under @${backlogItemsDir}/todo/ and @${backlogItemsDir}/completed/. Do not propose abstractions whose util name already appears in either directory.
643
722
 
644
723
  Pick exactly one new abstraction that:
645
724
  - 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).
725
+ ${materializeLine}
726
+ - Would shrink the codebase: refactoring all call sites in ${refactorScope} should reduce net line count (excluding new unit tests).
648
727
 
649
728
  Create exactly one new backlog item folder at @${backlogItemsDir}/todo/<utilName>/ with:
650
729
  - desc.yml containing:
@@ -664,6 +743,11 @@ Do not take too much time looking for every possible abstraction. Once you found
664
743
  `.trim();
665
744
  }
666
745
 
746
+ const FEATURE_BACKLOG_WORKFLOWS = [
747
+ 'tdd',
748
+ 'directImpl',
749
+ 'manual',
750
+ ];
667
751
  const RESERVED_NAME_SUFFIXES = ['_req', '_testPlan', '_tests_impl'];
668
752
  function assertValidFeatureItemName(name) {
669
753
  for (const suffix of RESERVED_NAME_SUFFIXES) {
@@ -672,6 +756,9 @@ function assertValidFeatureItemName(name) {
672
756
  }
673
757
  }
674
758
  }
759
+ function featureItemContextBaseName(item) {
760
+ return item.parentName ? `${item.parentName}-${item.name}` : item.name;
761
+ }
675
762
  function featureContextName(itemName, stage) {
676
763
  switch (stage) {
677
764
  case 'makeReq':
@@ -681,6 +768,7 @@ function featureContextName(itemName, stage) {
681
768
  case 'testImpl':
682
769
  return `${itemName}_tests_impl`;
683
770
  case 'implementation':
771
+ case 'directImpl':
684
772
  return itemName;
685
773
  default: {
686
774
  const _exhaustive = stage;
@@ -688,9 +776,84 @@ function featureContextName(itemName, stage) {
688
776
  }
689
777
  }
690
778
  }
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');
779
+ function parentNameFromTodoRelativeDir(todoRelativeDir) {
780
+ const parts = todoRelativeDir.split('/');
781
+ if (parts.length === 3 && parts[1] === 'tickets') {
782
+ return parts[0];
783
+ }
784
+ return undefined;
785
+ }
786
+ function parseFeatureWorkflow(itemName, raw) {
787
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
788
+ return undefined;
789
+ }
790
+ const record = raw;
791
+ if (record.workflow === undefined) {
792
+ return undefined;
793
+ }
794
+ if (typeof record.workflow !== 'string' ||
795
+ !FEATURE_BACKLOG_WORKFLOWS.includes(record.workflow)) {
796
+ throw new Error(`Backlog item "${itemName}" field "workflow" must be one of: ${FEATURE_BACKLOG_WORKFLOWS.join(', ')}`);
797
+ }
798
+ return record.workflow;
799
+ }
800
+ async function resolveItemWorkflow(input) {
801
+ const { item, paths, projectRoot } = input;
802
+ if (item.workflow !== undefined) {
803
+ return item.workflow;
804
+ }
805
+ if (item.parentName === undefined) {
806
+ return 'tdd';
807
+ }
808
+ const parentDescPath = path$1.join(projectRoot, paths.backlogItemsDir, 'todo', item.parentName, 'desc.yml');
809
+ let rawText;
810
+ try {
811
+ rawText = await fs$1.readFile(parentDescPath, 'utf-8');
812
+ }
813
+ catch (error) {
814
+ const err = error;
815
+ if (err.code === 'ENOENT') {
816
+ return 'tdd';
817
+ }
818
+ throw error;
819
+ }
820
+ return parseFeatureWorkflow(item.parentName, jsYaml.load(rawText)) ?? 'tdd';
821
+ }
822
+ /**
823
+ * `dev` → only top-level `directImpl` (tickets never run on `dev`, even if `directImpl`).
824
+ * `feature/<key>` → exact item name, or the parent todo name for tickets.
825
+ * `manual` never reaches here (`resolveFeatureBacklogItem` ignores it first).
826
+ */
827
+ function itemMatchesDiscoveryBranch(input) {
828
+ const { itemName, parentName, discoveryBranch, workflow } = input;
829
+ if (discoveryBranch === 'dev') {
830
+ return workflow === 'directImpl' && parentName === undefined;
831
+ }
832
+ if (!discoveryBranch.startsWith('feature/')) {
833
+ return false;
834
+ }
835
+ const key = discoveryBranch.slice('feature/'.length);
836
+ return (parentName ?? itemName) === key;
837
+ }
838
+ async function resolveFeatureBacklogItem(input) {
839
+ const { item, paths, projectRoot, discoveryBranch } = input;
840
+ const contextBaseName = featureItemContextBaseName(item);
841
+ const workflow = await resolveItemWorkflow({ item, paths, projectRoot });
842
+ if (workflow === 'manual') {
843
+ return { ignored: true };
844
+ }
845
+ if (!!item.completedAt ||
846
+ !itemMatchesDiscoveryBranch({
847
+ itemName: item.name,
848
+ parentName: item.parentName,
849
+ discoveryBranch,
850
+ workflow,
851
+ })) {
852
+ return { ignored: true };
853
+ }
854
+ const itemDir = path$1.join(paths.backlogItemsDir, 'todo', item.todoRelativeDir);
855
+ const reqFilePath = path$1.join(itemDir, 'requirements.md');
856
+ const testPlanFilePath = path$1.join(itemDir, 'testPlan.md');
694
857
  const hasReq = await core.pathExists(path$1.join(projectRoot, reqFilePath));
695
858
  if (!hasReq) {
696
859
  if (item.manualReq === true) {
@@ -698,7 +861,14 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
698
861
  }
699
862
  return {
700
863
  stage: 'makeReq',
701
- contextName: featureContextName(item.name, 'makeReq'),
864
+ contextName: featureContextName(contextBaseName, 'makeReq'),
865
+ variables: { REQ_FILE: reqFilePath },
866
+ };
867
+ }
868
+ if (workflow === 'directImpl') {
869
+ return {
870
+ stage: 'directImpl',
871
+ contextName: featureContextName(contextBaseName, 'directImpl'),
702
872
  variables: { REQ_FILE: reqFilePath },
703
873
  };
704
874
  }
@@ -706,24 +876,24 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
706
876
  if (!hasTestPlan) {
707
877
  return {
708
878
  stage: 'makeTestPlan',
709
- contextName: featureContextName(item.name, 'makeTestPlan'),
879
+ contextName: featureContextName(contextBaseName, 'makeTestPlan'),
710
880
  variables: {
711
881
  REQ_FILE: reqFilePath,
712
882
  TEST_PLAN_FILE: testPlanFilePath,
713
883
  },
714
884
  };
715
885
  }
716
- const testsImplContextName = featureContextName(item.name, 'testImpl');
886
+ const testsImplContextName = featureContextName(contextBaseName, 'testImpl');
717
887
  const testsImplStatus = await cliUtils.getContextStatus({
718
888
  projectRoot,
719
889
  contextName: testsImplContextName,
720
- lumpName,
721
- baseBranch,
890
+ lumpName: paths.lumpName,
891
+ baseBranch: discoveryBranch,
722
892
  });
723
893
  if (testsImplStatus === 'finished') {
724
894
  return {
725
895
  stage: 'implementation',
726
- contextName: featureContextName(item.name, 'implementation'),
896
+ contextName: featureContextName(contextBaseName, 'implementation'),
727
897
  variables: {
728
898
  REQ_FILE: reqFilePath,
729
899
  TEST_PLAN_FILE: testPlanFilePath,
@@ -743,27 +913,38 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
743
913
  };
744
914
  }
745
915
  const featureBacklog = defineRecipe(function featureBacklog(options) {
746
- const { configUrl, baseBranch, implValidateCommand, backlogItemsDir, ...rest } = options;
916
+ const { configUrl, implValidateCommand, backlogItemsDir, ...rest } = options;
747
917
  const projectRoot = projectRootFromConfigUrl(configUrl);
748
918
  const runImplValidation = resolveImplValidateCommand(implValidateCommand ??
749
919
  'echo "No implementation validation command provided. I say, trust but verify, but well..."');
750
920
  return backlog({
751
921
  configUrl,
752
922
  backlogItemsDir,
753
- baseBranch,
754
- parseItem(baseItem, _folderName, raw) {
923
+ parseItem(baseItem, folderName, raw) {
755
924
  assertValidFeatureItemName(baseItem.name);
756
925
  const record = raw;
757
926
  if (record.manualReq !== undefined && typeof record.manualReq !== 'boolean') {
758
927
  throw new Error(`Backlog item "${baseItem.name}" field "manualReq" must be a boolean`);
759
928
  }
929
+ const parentName = parentNameFromTodoRelativeDir(folderName);
760
930
  return {
761
931
  ...baseItem,
932
+ todoRelativeDir: folderName,
933
+ parentName,
934
+ dependsOn: parentName
935
+ ? baseItem.dependsOn?.map((dep) => `${parentName}-${dep}`)
936
+ : baseItem.dependsOn,
762
937
  manualReq: record.manualReq === true ? true : undefined,
938
+ workflow: parseFeatureWorkflow(baseItem.name, raw),
763
939
  };
764
940
  },
765
- async resolveItem({ item, paths }) {
766
- return resolveFeatureBacklogItem(item, paths, projectRoot, paths.lumpName, baseBranch);
941
+ async resolveItem({ item, paths, discoveryBranch }) {
942
+ return resolveFeatureBacklogItem({
943
+ item,
944
+ paths,
945
+ projectRoot,
946
+ discoveryBranch,
947
+ });
767
948
  },
768
949
  stages: {
769
950
  makeReq: {
@@ -912,6 +1093,25 @@ The implementation should make the tests pass. Do not edit any test file except
912
1093
  validationCommandFn: runImplValidation,
913
1094
  }),
914
1095
  },
1096
+ directImpl: {
1097
+ completion: 'moveToDone',
1098
+ steps: retryUntilGreen({
1099
+ steps: [
1100
+ {
1101
+ promptFn({ context: ctx }) {
1102
+ const vars = ctx.variables;
1103
+ const { REQ_FILE } = vars;
1104
+ return `
1105
+ Implement the feature described in @${REQ_FILE}.
1106
+ Add or update tests as needed so the suite covers the change, and make validation pass.
1107
+ Do not edit @${REQ_FILE} unless absolutely necessary.
1108
+ `.trim();
1109
+ },
1110
+ },
1111
+ ],
1112
+ validationCommandFn: runImplValidation,
1113
+ }),
1114
+ },
915
1115
  },
916
1116
  ...rest,
917
1117
  });
@@ -922,6 +1122,7 @@ exports.BACKLOG_ITEM_DIR_VAR = BACKLOG_ITEM_DIR_VAR;
922
1122
  exports.BACKLOG_STAGE_VAR = BACKLOG_STAGE_VAR;
923
1123
  exports.BACKLOG_TASK_NAME_VAR = BACKLOG_TASK_NAME_VAR;
924
1124
  exports.BACKLOG_TASK_VAR = BACKLOG_TASK_VAR;
1125
+ exports.FEATURE_BACKLOG_WORKFLOWS = FEATURE_BACKLOG_WORKFLOWS;
925
1126
  exports.abstractionBacklog = abstractionBacklog;
926
1127
  exports.abstractionFinder = abstractionFinder;
927
1128
  exports.backlog = backlog;
@@ -932,8 +1133,10 @@ exports.featureBacklog = featureBacklog;
932
1133
  exports.folderBacklogContexts = folderBacklogContexts;
933
1134
  exports.folderSetTaskDoneStep = folderSetTaskDoneStep;
934
1135
  exports.getRecursiveSteps = getRecursiveSteps;
1136
+ exports.listTodoRelativeDirs = listTodoRelativeDirs;
935
1137
  exports.lumpPathAndName = lumpPathAndName;
936
1138
  exports.normalizeMaybePromGetter = normalizeMaybePromGetter;
1139
+ exports.parseFeatureWorkflow = parseFeatureWorkflow;
937
1140
  exports.projectRootFromConfigUrl = projectRootFromConfigUrl;
938
1141
  exports.requireArtifactStep = requireArtifactStep;
939
1142
  exports.resolveBacklogPaths = resolveBacklogPaths;
package/dist/index.d.ts CHANGED
@@ -92,15 +92,21 @@ type DoneBacklogItem<T extends BaseBacklogItem = BaseBacklogItem> = T & {
92
92
 
93
93
  type FolderBacklogContextsOptions<Item extends BaseBacklogItem = BaseBacklogItem> = {
94
94
  backlogItemsDir: string;
95
+ /**
96
+ * `folderName` is the path relative to `todo/`: the item folder, or
97
+ * `<parent>/tickets/<ticket>` when the parent has a `tickets/` directory.
98
+ */
95
99
  parseItem?: (item: BaseBacklogItem, folderName: string, raw: unknown) => Item;
96
100
  parseContext?: (item: Item, folderName: string) => MaybePromise$1<{
97
101
  parsed?: Partial<Context>;
98
102
  ignored?: boolean;
99
103
  }>;
100
104
  };
105
+ /** Path relative to `todo/`, using `/` so it is stable in context variables. */
106
+ declare function listTodoRelativeDirs(todoDir: string): Promise<string[]>;
101
107
  declare function folderBacklogContexts<Item extends BaseBacklogItem = BaseBacklogItem, V extends LumpVariables = LumpVariables>({ backlogItemsDir, parseItem, parseContext, }: FolderBacklogContextsOptions<Item>): GetContextListFn<V>;
102
108
 
103
- /** Moves a finished backlog item folder from todo/ to completed/ after the context completes. */
109
+ /** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
104
110
  declare function folderSetTaskDoneStep<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(input: {
105
111
  itemsDirVarName: string;
106
112
  nameVarName?: string;
@@ -136,12 +142,20 @@ type AbstractionBacklogOptions<V extends LumpVariables = LumpVariables, SV exten
136
142
  } & Omit<LumpJsConfig<V, SV>, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt' | 'steps'>;
137
143
  declare const abstractionBacklog: <V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(options: AbstractionBacklogOptions<V, SV>) => LumpJsConfig<V, SV>;
138
144
 
145
+ type AbstractionFinderScanCommand<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = string | CommandDescriptor | CommandFn<V, SV>;
139
146
  type AbstractionFinderOptions<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = {
147
+ /** Lump config module URL — pass `import.meta.url` from `config.ts`. */
148
+ configUrl: string | URL;
140
149
  scanDirectories?: string[];
150
+ /** Optional util output directory (project-root-relative). Defaults to the first scan directory. */
151
+ utilDir?: string;
141
152
  customPrompt?(): string;
153
+ /** Max unmerged items under `backlogItemsDir/todo/`. Finder emits one context per tick while under the cap. */
142
154
  maxPendingAbstractions?: number;
143
155
  backlogItemsDir: string;
144
- } & LumpJsConfig<V, SV>;
156
+ /** Optional scanner command prepended before the prompt (e.g. a dupes report). */
157
+ scanCommand?: AbstractionFinderScanCommand<V, SV>;
158
+ } & Omit<LumpJsConfig<V, SV>, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt'>;
145
159
  declare const abstractionFinder: <V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(options: AbstractionFinderOptions<V, SV>) => LumpJsConfig<V, SV>;
146
160
 
147
161
  type BacklogStageDefinition<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = {
@@ -178,10 +192,18 @@ declare const BACKLOG_ITEM_DIR_VAR = "BACKLOG_ITEM_DIR";
178
192
  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
193
  declare const backlogRecipe: typeof backlog;
180
194
 
195
+ type FeatureBacklogWorkflow = 'tdd' | 'directImpl' | 'manual';
196
+ type FeatureBacklogRunnableWorkflow = Exclude<FeatureBacklogWorkflow, 'manual'>;
181
197
  type FeatureBacklogItem = BaseBacklogItem & {
182
198
  manualReq?: boolean;
199
+ workflow?: FeatureBacklogWorkflow;
200
+ completedAt?: string;
201
+ /** Path relative to `backlogItems/todo/`; tickets live at `<parent>/tickets/<name>`. */
202
+ todoRelativeDir: string;
203
+ /** Parent todo folder name when this item is a ticket. */
204
+ parentName?: string;
183
205
  };
184
- type FeatureBacklogStage = 'makeReq' | 'makeTestPlan' | 'testImpl' | 'implementation';
206
+ type FeatureBacklogStage = 'makeReq' | 'makeTestPlan' | 'testImpl' | 'implementation' | 'directImpl';
185
207
  type FeatureBacklogContextVariables = {
186
208
  TASK_NAME: string;
187
209
  TASK: string;
@@ -193,12 +215,18 @@ type FeatureBacklogContextVariables = {
193
215
  };
194
216
  type FeatureBacklogOptions<V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables> = {
195
217
  configUrl: string | URL;
196
- baseBranch: string;
197
218
  implValidateCommand?: ValidationCommandFn<V, SV> | string;
198
219
  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>>;
220
+ } & Omit<LumpJsConfig<V, SV>, 'contextListJson' | 'contextMatchFn' | 'getContextListFn' | 'prompt' | 'steps'>;
221
+ declare const FEATURE_BACKLOG_WORKFLOWS: readonly ["tdd", "directImpl", "manual"];
222
+ declare function parseFeatureWorkflow(itemName: string, raw: unknown): FeatureBacklogWorkflow | undefined;
223
+ declare function resolveFeatureBacklogItem(input: {
224
+ item: FeatureBacklogItem;
225
+ paths: BacklogPaths;
226
+ projectRoot: string;
227
+ discoveryBranch: string;
228
+ }): Promise<BacklogItemResolution<FeatureBacklogStage>>;
201
229
  declare const featureBacklog: <V extends LumpVariables = LumpVariables, SV extends StepVariables = StepVariables>(options: FeatureBacklogOptions<V, SV>) => LumpJsConfig<V, SV>;
202
230
 
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 };
231
+ export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, FEATURE_BACKLOG_WORKFLOWS, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, listTodoRelativeDirs, lumpPathAndName, normalizeMaybePromGetter, parseFeatureWorkflow, projectRootFromConfigUrl, requireArtifactStep, resolveBacklogPaths, resolveFeatureBacklogItem, resolveImplValidateCommand, retryUntilGreen, setTaskDoneStep, shellCommand, validateBaseBacklogItem, ymlBacklogContexts };
232
+ export type { AbstractionBacklogOptions, AbstractionFinderOptions, AbstractionFinderScanCommand, BacklogItemResolution, BacklogOptions, BacklogPaths, BacklogStageDefinition, BaseBacklogItem, DoneBacklogItem, EphemeralContextListFnOptions, FeatureBacklogContextVariables, FeatureBacklogItem, FeatureBacklogOptions, FeatureBacklogRunnableWorkflow, FeatureBacklogStage, FeatureBacklogWorkflow, FolderBacklogContextsOptions, GetFirstStepsInput, GetRecursiveStepsOptions, ImplValidateCommand, IsValidationCommandResultOkInput, MaybePromGetter, Recipe, RetryUntilGreenInput, StepIndex, ValidationCommandFn, ValidationCommandFnInput, YmlBacklogContextsOptions };
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ 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) {
@@ -284,10 +285,26 @@ async function listTodoFolderNames(todoDir) {
284
285
  throw error;
285
286
  }
286
287
  }
288
+ /** Path relative to `todo/`, using `/` so it is stable in context variables. */
289
+ async function listTodoRelativeDirs(todoDir) {
290
+ const topNames = await listTodoFolderNames(todoDir);
291
+ const relativeDirs = [];
292
+ for (const name of topNames) {
293
+ const ticketNames = await listTodoFolderNames(path$1.join(todoDir, name, 'tickets'));
294
+ if (ticketNames.length === 0) {
295
+ relativeDirs.push(name);
296
+ continue;
297
+ }
298
+ for (const ticketName of ticketNames) {
299
+ relativeDirs.push(`${name}/tickets/${ticketName}`);
300
+ }
301
+ }
302
+ return relativeDirs;
303
+ }
287
304
  function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
288
305
  return async () => {
289
306
  const todoDir = path$1.join(backlogItemsDir, 'todo');
290
- const folderNames = await listTodoFolderNames(todoDir);
307
+ const folderNames = await listTodoRelativeDirs(todoDir);
291
308
  const discovered = await Promise.all(folderNames.map(async (folderName) => {
292
309
  const descPath = path$1.join(todoDir, folderName, 'desc.yml');
293
310
  let rawText;
@@ -302,13 +319,22 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
302
319
  throw error;
303
320
  }
304
321
  const raw = load(rawText);
322
+ const itemFolderName = path$1.basename(folderName);
305
323
  const baseItem = validateBaseBacklogItem(raw, `in folder "${folderName}"`);
306
- if (baseItem.name !== folderName) {
324
+ if (baseItem.name !== itemFolderName) {
307
325
  throw new Error(`Backlog item folder "${folderName}" desc.yml name "${baseItem.name}" must match folder name`);
308
326
  }
309
327
  const item = parseItem ? parseItem(baseItem, folderName, raw) : baseItem;
310
328
  return { item, folderName };
311
329
  }));
330
+ const seenNames = new Map();
331
+ for (const { item, folderName } of discovered) {
332
+ const previous = seenNames.get(item.name);
333
+ if (previous !== undefined) {
334
+ throw new Error(`Duplicate backlog item name "${item.name}" in folders "${previous}" and "${folderName}"`);
335
+ }
336
+ seenNames.set(item.name, folderName);
337
+ }
312
338
  discovered.sort((a, b) => {
313
339
  if (a.item.priority !== b.item.priority) {
314
340
  return a.item.priority - b.item.priority;
@@ -339,7 +365,7 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
339
365
  function isPlainObject(value) {
340
366
  return typeof value === 'object' && value !== null && !Array.isArray(value);
341
367
  }
342
- /** Moves a finished backlog item folder from todo/ to completed/ after the context completes. */
368
+ /** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
343
369
  function folderSetTaskDoneStep(input) {
344
370
  const nameVarName = input.nameVarName ?? 'TASK_NAME';
345
371
  return {
@@ -347,19 +373,29 @@ function folderSetTaskDoneStep(input) {
347
373
  const variables = context.variables;
348
374
  const itemsDirRelative = variables[input.itemsDirVarName];
349
375
  const taskName = variables[nameVarName];
350
- if (!itemsDirRelative || !taskName) {
376
+ const itemDirRelative = variables.BACKLOG_ITEM_DIR;
377
+ if (!itemsDirRelative || (!itemDirRelative && !taskName)) {
351
378
  throw new Error('Backlog items directory and task name are required');
352
379
  }
353
380
  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);
381
+ const todoDir = path$1.join(itemsDir, 'todo');
382
+ const fromDir = itemDirRelative
383
+ ? path$1.join(workspacePath, itemDirRelative)
384
+ : path$1.join(todoDir, taskName);
385
+ const relativeFromTodo = path$1.relative(todoDir, fromDir);
386
+ if (relativeFromTodo === '' ||
387
+ relativeFromTodo.startsWith('..') ||
388
+ path$1.isAbsolute(relativeFromTodo)) {
389
+ throw new Error(`BACKLOG_ITEM_DIR must be a folder under ${todoDir}: ${itemDirRelative ?? fromDir}`);
390
+ }
391
+ const toDir = path$1.join(itemsDir, 'completed', relativeFromTodo);
356
392
  const descPath = path$1.join(fromDir, 'desc.yml');
357
393
  const completedDescPath = path$1.join(toDir, 'desc.yml');
358
394
  if (!(await pathExists(fromDir))) {
359
395
  return null;
360
396
  }
361
397
  if (await pathExists(toDir)) {
362
- console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName}": already exists at ${toDir}`);
398
+ console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
363
399
  return null;
364
400
  }
365
401
  const rawText = await fs.readFile(descPath, 'utf-8');
@@ -371,7 +407,7 @@ function folderSetTaskDoneStep(input) {
371
407
  ...raw,
372
408
  completedAt: new Date().toISOString(),
373
409
  };
374
- await fs.mkdir(path$1.join(itemsDir, 'completed'), { recursive: true });
410
+ await fs.mkdir(path$1.dirname(toDir), { recursive: true });
375
411
  await fs.rename(fromDir, toDir);
376
412
  await fs.writeFile(completedDescPath, dump(updated));
377
413
  return {
@@ -620,29 +656,72 @@ const abstractionBacklog = defineRecipe(function abstractionBacklog(options) {
620
656
  });
621
657
  });
622
658
 
659
+ function scanCommandStep(scanCommand) {
660
+ if (typeof scanCommand === 'function') {
661
+ return { commandFn: scanCommand };
662
+ }
663
+ if (typeof scanCommand === 'string') {
664
+ return { commandFn: () => shellCommand(scanCommand) };
665
+ }
666
+ return { commandFn: () => scanCommand };
667
+ }
623
668
  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
669
+ const { configUrl, maxPendingAbstractions = 5, scanDirectories, utilDir, customPrompt, backlogItemsDir, scanCommand, steps: stepsOverride, ...rest } = options;
670
+ if (path$1.isAbsolute(backlogItemsDir)) {
671
+ throw new Error(`backlogItemsDir must be project-root-relative, not absolute: ${backlogItemsDir}`);
672
+ }
673
+ const projectRoot = projectRootFromConfigUrl(configUrl);
674
+ const todoDir = path$1.join(projectRoot, backlogItemsDir, 'todo');
675
+ const agentSteps = stepsOverride ?? buildFinderPrompt({
676
+ backlogItemsDir,
677
+ scanDirectories,
678
+ utilDir,
679
+ customPrompt,
680
+ });
681
+ const steps = [
682
+ ...(scanCommand === undefined ? [] : [scanCommandStep(scanCommand)]),
683
+ ...normalizeSteps({
684
+ prompt: undefined,
685
+ jsSteps: agentSteps,
686
+ }),
687
+ ];
625
688
  return defineConfig({
626
- ...options,
689
+ ...rest,
627
690
  getContextListFn: ephemeralContextListFn({
628
- contextCount: maxPendingAbstractions,
691
+ async contextCount() {
692
+ const pending = (await listTodoRelativeDirs(todoDir)).length;
693
+ return pending >= maxPendingAbstractions ? 0 : 1;
694
+ },
629
695
  variables: {
630
696
  BACKLOG_ITEMS_DIR: backlogItemsDir,
631
697
  },
632
698
  }),
633
- steps: buildFinderPrompt({ backlogItemsDir, scanDirectories, customPrompt }),
699
+ steps,
634
700
  });
635
701
  });
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).
702
+ function buildFinderPrompt({ backlogItemsDir, scanDirectories, utilDir, customPrompt, }) {
703
+ if (customPrompt) {
704
+ return customPrompt();
705
+ }
706
+ const scanLabel = scanDirectories && scanDirectories.length > 0
707
+ ? scanDirectories.map((dir) => `@${dir}`).join(' and ')
708
+ : 'the codebase';
709
+ const materializeDir = utilDir ?? scanDirectories?.[0];
710
+ const materializeLine = materializeDir
711
+ ? `- Would materialize as a new util under @${materializeDir}/<utilName>/, following existing conventions there.`
712
+ : '- Would materialize as a new util in the scanned tree, following existing conventions there.';
713
+ const refactorScope = scanDirectories && scanDirectories.length > 0
714
+ ? scanDirectories.join(' and ')
715
+ : 'the scanned tree';
716
+ return `
717
+ Scan ${scanLabel} for duplicated logic that appears in multiple places (same pattern, not merely similar file structure).
639
718
 
640
719
  List existing backlog item names under @${backlogItemsDir}/todo/ and @${backlogItemsDir}/completed/. Do not propose abstractions whose util name already appears in either directory.
641
720
 
642
721
  Pick exactly one new abstraction that:
643
722
  - 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).
723
+ ${materializeLine}
724
+ - Would shrink the codebase: refactoring all call sites in ${refactorScope} should reduce net line count (excluding new unit tests).
646
725
 
647
726
  Create exactly one new backlog item folder at @${backlogItemsDir}/todo/<utilName>/ with:
648
727
  - desc.yml containing:
@@ -662,6 +741,11 @@ Do not take too much time looking for every possible abstraction. Once you found
662
741
  `.trim();
663
742
  }
664
743
 
744
+ const FEATURE_BACKLOG_WORKFLOWS = [
745
+ 'tdd',
746
+ 'directImpl',
747
+ 'manual',
748
+ ];
665
749
  const RESERVED_NAME_SUFFIXES = ['_req', '_testPlan', '_tests_impl'];
666
750
  function assertValidFeatureItemName(name) {
667
751
  for (const suffix of RESERVED_NAME_SUFFIXES) {
@@ -670,6 +754,9 @@ function assertValidFeatureItemName(name) {
670
754
  }
671
755
  }
672
756
  }
757
+ function featureItemContextBaseName(item) {
758
+ return item.parentName ? `${item.parentName}-${item.name}` : item.name;
759
+ }
673
760
  function featureContextName(itemName, stage) {
674
761
  switch (stage) {
675
762
  case 'makeReq':
@@ -679,6 +766,7 @@ function featureContextName(itemName, stage) {
679
766
  case 'testImpl':
680
767
  return `${itemName}_tests_impl`;
681
768
  case 'implementation':
769
+ case 'directImpl':
682
770
  return itemName;
683
771
  default: {
684
772
  const _exhaustive = stage;
@@ -686,9 +774,84 @@ function featureContextName(itemName, stage) {
686
774
  }
687
775
  }
688
776
  }
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');
777
+ function parentNameFromTodoRelativeDir(todoRelativeDir) {
778
+ const parts = todoRelativeDir.split('/');
779
+ if (parts.length === 3 && parts[1] === 'tickets') {
780
+ return parts[0];
781
+ }
782
+ return undefined;
783
+ }
784
+ function parseFeatureWorkflow(itemName, raw) {
785
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
786
+ return undefined;
787
+ }
788
+ const record = raw;
789
+ if (record.workflow === undefined) {
790
+ return undefined;
791
+ }
792
+ if (typeof record.workflow !== 'string' ||
793
+ !FEATURE_BACKLOG_WORKFLOWS.includes(record.workflow)) {
794
+ throw new Error(`Backlog item "${itemName}" field "workflow" must be one of: ${FEATURE_BACKLOG_WORKFLOWS.join(', ')}`);
795
+ }
796
+ return record.workflow;
797
+ }
798
+ async function resolveItemWorkflow(input) {
799
+ const { item, paths, projectRoot } = input;
800
+ if (item.workflow !== undefined) {
801
+ return item.workflow;
802
+ }
803
+ if (item.parentName === undefined) {
804
+ return 'tdd';
805
+ }
806
+ const parentDescPath = path$1.join(projectRoot, paths.backlogItemsDir, 'todo', item.parentName, 'desc.yml');
807
+ let rawText;
808
+ try {
809
+ rawText = await fs$1.readFile(parentDescPath, 'utf-8');
810
+ }
811
+ catch (error) {
812
+ const err = error;
813
+ if (err.code === 'ENOENT') {
814
+ return 'tdd';
815
+ }
816
+ throw error;
817
+ }
818
+ return parseFeatureWorkflow(item.parentName, load(rawText)) ?? 'tdd';
819
+ }
820
+ /**
821
+ * `dev` → only top-level `directImpl` (tickets never run on `dev`, even if `directImpl`).
822
+ * `feature/<key>` → exact item name, or the parent todo name for tickets.
823
+ * `manual` never reaches here (`resolveFeatureBacklogItem` ignores it first).
824
+ */
825
+ function itemMatchesDiscoveryBranch(input) {
826
+ const { itemName, parentName, discoveryBranch, workflow } = input;
827
+ if (discoveryBranch === 'dev') {
828
+ return workflow === 'directImpl' && parentName === undefined;
829
+ }
830
+ if (!discoveryBranch.startsWith('feature/')) {
831
+ return false;
832
+ }
833
+ const key = discoveryBranch.slice('feature/'.length);
834
+ return (parentName ?? itemName) === key;
835
+ }
836
+ async function resolveFeatureBacklogItem(input) {
837
+ const { item, paths, projectRoot, discoveryBranch } = input;
838
+ const contextBaseName = featureItemContextBaseName(item);
839
+ const workflow = await resolveItemWorkflow({ item, paths, projectRoot });
840
+ if (workflow === 'manual') {
841
+ return { ignored: true };
842
+ }
843
+ if (!!item.completedAt ||
844
+ !itemMatchesDiscoveryBranch({
845
+ itemName: item.name,
846
+ parentName: item.parentName,
847
+ discoveryBranch,
848
+ workflow,
849
+ })) {
850
+ return { ignored: true };
851
+ }
852
+ const itemDir = path$1.join(paths.backlogItemsDir, 'todo', item.todoRelativeDir);
853
+ const reqFilePath = path$1.join(itemDir, 'requirements.md');
854
+ const testPlanFilePath = path$1.join(itemDir, 'testPlan.md');
692
855
  const hasReq = await pathExists(path$1.join(projectRoot, reqFilePath));
693
856
  if (!hasReq) {
694
857
  if (item.manualReq === true) {
@@ -696,7 +859,14 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
696
859
  }
697
860
  return {
698
861
  stage: 'makeReq',
699
- contextName: featureContextName(item.name, 'makeReq'),
862
+ contextName: featureContextName(contextBaseName, 'makeReq'),
863
+ variables: { REQ_FILE: reqFilePath },
864
+ };
865
+ }
866
+ if (workflow === 'directImpl') {
867
+ return {
868
+ stage: 'directImpl',
869
+ contextName: featureContextName(contextBaseName, 'directImpl'),
700
870
  variables: { REQ_FILE: reqFilePath },
701
871
  };
702
872
  }
@@ -704,24 +874,24 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
704
874
  if (!hasTestPlan) {
705
875
  return {
706
876
  stage: 'makeTestPlan',
707
- contextName: featureContextName(item.name, 'makeTestPlan'),
877
+ contextName: featureContextName(contextBaseName, 'makeTestPlan'),
708
878
  variables: {
709
879
  REQ_FILE: reqFilePath,
710
880
  TEST_PLAN_FILE: testPlanFilePath,
711
881
  },
712
882
  };
713
883
  }
714
- const testsImplContextName = featureContextName(item.name, 'testImpl');
884
+ const testsImplContextName = featureContextName(contextBaseName, 'testImpl');
715
885
  const testsImplStatus = await getContextStatus({
716
886
  projectRoot,
717
887
  contextName: testsImplContextName,
718
- lumpName,
719
- baseBranch,
888
+ lumpName: paths.lumpName,
889
+ baseBranch: discoveryBranch,
720
890
  });
721
891
  if (testsImplStatus === 'finished') {
722
892
  return {
723
893
  stage: 'implementation',
724
- contextName: featureContextName(item.name, 'implementation'),
894
+ contextName: featureContextName(contextBaseName, 'implementation'),
725
895
  variables: {
726
896
  REQ_FILE: reqFilePath,
727
897
  TEST_PLAN_FILE: testPlanFilePath,
@@ -741,27 +911,38 @@ async function resolveFeatureBacklogItem(item, paths, projectRoot, lumpName, bas
741
911
  };
742
912
  }
743
913
  const featureBacklog = defineRecipe(function featureBacklog(options) {
744
- const { configUrl, baseBranch, implValidateCommand, backlogItemsDir, ...rest } = options;
914
+ const { configUrl, implValidateCommand, backlogItemsDir, ...rest } = options;
745
915
  const projectRoot = projectRootFromConfigUrl(configUrl);
746
916
  const runImplValidation = resolveImplValidateCommand(implValidateCommand ??
747
917
  'echo "No implementation validation command provided. I say, trust but verify, but well..."');
748
918
  return backlog({
749
919
  configUrl,
750
920
  backlogItemsDir,
751
- baseBranch,
752
- parseItem(baseItem, _folderName, raw) {
921
+ parseItem(baseItem, folderName, raw) {
753
922
  assertValidFeatureItemName(baseItem.name);
754
923
  const record = raw;
755
924
  if (record.manualReq !== undefined && typeof record.manualReq !== 'boolean') {
756
925
  throw new Error(`Backlog item "${baseItem.name}" field "manualReq" must be a boolean`);
757
926
  }
927
+ const parentName = parentNameFromTodoRelativeDir(folderName);
758
928
  return {
759
929
  ...baseItem,
930
+ todoRelativeDir: folderName,
931
+ parentName,
932
+ dependsOn: parentName
933
+ ? baseItem.dependsOn?.map((dep) => `${parentName}-${dep}`)
934
+ : baseItem.dependsOn,
760
935
  manualReq: record.manualReq === true ? true : undefined,
936
+ workflow: parseFeatureWorkflow(baseItem.name, raw),
761
937
  };
762
938
  },
763
- async resolveItem({ item, paths }) {
764
- return resolveFeatureBacklogItem(item, paths, projectRoot, paths.lumpName, baseBranch);
939
+ async resolveItem({ item, paths, discoveryBranch }) {
940
+ return resolveFeatureBacklogItem({
941
+ item,
942
+ paths,
943
+ projectRoot,
944
+ discoveryBranch,
945
+ });
765
946
  },
766
947
  stages: {
767
948
  makeReq: {
@@ -910,9 +1091,28 @@ The implementation should make the tests pass. Do not edit any test file except
910
1091
  validationCommandFn: runImplValidation,
911
1092
  }),
912
1093
  },
1094
+ directImpl: {
1095
+ completion: 'moveToDone',
1096
+ steps: retryUntilGreen({
1097
+ steps: [
1098
+ {
1099
+ promptFn({ context: ctx }) {
1100
+ const vars = ctx.variables;
1101
+ const { REQ_FILE } = vars;
1102
+ return `
1103
+ Implement the feature described in @${REQ_FILE}.
1104
+ Add or update tests as needed so the suite covers the change, and make validation pass.
1105
+ Do not edit @${REQ_FILE} unless absolutely necessary.
1106
+ `.trim();
1107
+ },
1108
+ },
1109
+ ],
1110
+ validationCommandFn: runImplValidation,
1111
+ }),
1112
+ },
913
1113
  },
914
1114
  ...rest,
915
1115
  });
916
1116
  });
917
1117
 
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 };
1118
+ export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, FEATURE_BACKLOG_WORKFLOWS, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, listTodoRelativeDirs, lumpPathAndName, normalizeMaybePromGetter, 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.0",
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.0",
48
+ "@lumpcode/core": "^0.3.0",
49
49
  "js-yaml": "^5.0.0"
50
50
  },
51
51
  "devDependencies": {