@lumpcode/recipes 0.3.0 → 0.4.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
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';
@@ -16,6 +16,111 @@ function shellCommand(script) {
16
16
  };
17
17
  }
18
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
+
19
124
  function normalizeMaybePromGetter(maybePromGetter, defaultValue) {
20
125
  if (typeof maybePromGetter === 'function') {
21
126
  return maybePromGetter;
@@ -285,26 +390,54 @@ async function listTodoFolderNames(todoDir) {
285
390
  throw error;
286
391
  }
287
392
  }
288
- /** Path relative to `todo/`, using `/` so it is stable in context variables. */
289
- async function listTodoRelativeDirs(todoDir) {
393
+ /** Live and completed ticket folder names for an umbrella parent, sorted uniquely. */
394
+ async function listUmbrellaTicketNames(input) {
395
+ const { todoDir, parentName } = input;
396
+ const completedDir = path$1.join(path$1.dirname(todoDir), 'completed');
397
+ const liveTicketNames = await listTodoFolderNames(path$1.join(todoDir, parentName, 'tickets'));
398
+ const completedTicketNames = await listTodoFolderNames(path$1.join(completedDir, parentName, 'tickets'));
399
+ return [...new Set([...liveTicketNames, ...completedTicketNames])].sort();
400
+ }
401
+ /**
402
+ * Path relative to `todo/`, using `/` so it is stable in context variables.
403
+ * A top-level folder is an umbrella (never itself an item) when it has live
404
+ * tickets, a leftover `tickets/` directory, or tickets already under
405
+ * sibling `completed/<name>/tickets/`.
406
+ */
407
+ async function listTodoRelativeDirs(todoDir, options) {
408
+ const includeUmbrellaParents = options?.includeUmbrellaParents ?? false;
290
409
  const topNames = await listTodoFolderNames(todoDir);
410
+ const completedDir = path$1.join(path$1.dirname(todoDir), 'completed');
291
411
  const relativeDirs = [];
292
412
  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);
413
+ const liveTicketsDir = path$1.join(todoDir, name, 'tickets');
414
+ const liveTicketNames = await listTodoFolderNames(liveTicketsDir);
415
+ const completedTicketNames = await listTodoFolderNames(path$1.join(completedDir, name, 'tickets'));
416
+ const hasAnyTicket = liveTicketNames.length > 0 || completedTicketNames.length > 0;
417
+ if (liveTicketNames.length > 0) {
418
+ for (const ticketName of liveTicketNames) {
419
+ relativeDirs.push(`${name}/tickets/${ticketName}`);
420
+ }
421
+ if (includeUmbrellaParents && hasAnyTicket) {
422
+ relativeDirs.push(name);
423
+ }
296
424
  continue;
297
425
  }
298
- for (const ticketName of ticketNames) {
299
- relativeDirs.push(`${name}/tickets/${ticketName}`);
426
+ const isUmbrella = (await pathExists(liveTicketsDir)) || completedTicketNames.length > 0;
427
+ if (isUmbrella) {
428
+ if (includeUmbrellaParents && hasAnyTicket) {
429
+ relativeDirs.push(name);
430
+ }
431
+ continue;
300
432
  }
433
+ relativeDirs.push(name);
301
434
  }
302
435
  return relativeDirs;
303
436
  }
304
- function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
437
+ function folderBacklogContexts({ backlogItemsDir, includeUmbrellaParents, parseItem, parseContext, }) {
305
438
  return async () => {
306
439
  const todoDir = path$1.join(backlogItemsDir, 'todo');
307
- const folderNames = await listTodoRelativeDirs(todoDir);
440
+ const folderNames = await listTodoRelativeDirs(todoDir, { includeUmbrellaParents });
308
441
  const discovered = await Promise.all(folderNames.map(async (folderName) => {
309
442
  const descPath = path$1.join(todoDir, folderName, 'desc.yml');
310
443
  let rawText;
@@ -365,6 +498,37 @@ function folderBacklogContexts({ backlogItemsDir, parseItem, parseContext, }) {
365
498
  function isPlainObject(value) {
366
499
  return typeof value === 'object' && value !== null && !Array.isArray(value);
367
500
  }
501
+ async function listSubdirNames(dir) {
502
+ try {
503
+ const entries = await fs.readdir(dir, { withFileTypes: true });
504
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
505
+ }
506
+ catch (error) {
507
+ const err = error;
508
+ if (err.code === 'ENOENT') {
509
+ return [];
510
+ }
511
+ throw error;
512
+ }
513
+ }
514
+ async function mergeTodoUmbrellaParentIntoCompleted(input) {
515
+ const { fromDir, toDir, completedDescPath, updatedDesc } = input;
516
+ await fs.mkdir(toDir, { recursive: true });
517
+ await fs.writeFile(completedDescPath, dump(updatedDesc));
518
+ const entries = await fs.readdir(fromDir, { withFileTypes: true });
519
+ for (const entry of entries) {
520
+ if (entry.name === 'desc.yml') {
521
+ continue;
522
+ }
523
+ const src = path$1.join(fromDir, entry.name);
524
+ const dest = path$1.join(toDir, entry.name);
525
+ if (await pathExists(dest)) {
526
+ continue;
527
+ }
528
+ await fs.rename(src, dest);
529
+ }
530
+ await fs.rm(fromDir, { recursive: true, force: true });
531
+ }
368
532
  /** Moves a finished backlog item folder from todo/ to the same relative path under completed/. */
369
533
  function folderSetTaskDoneStep(input) {
370
534
  const nameVarName = input.nameVarName ?? 'TASK_NAME';
@@ -394,10 +558,6 @@ function folderSetTaskDoneStep(input) {
394
558
  if (!(await pathExists(fromDir))) {
395
559
  return null;
396
560
  }
397
- if (await pathExists(toDir)) {
398
- console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
399
- return null;
400
- }
401
561
  const rawText = await fs.readFile(descPath, 'utf-8');
402
562
  const raw = load(rawText);
403
563
  if (!isPlainObject(raw)) {
@@ -407,6 +567,26 @@ function folderSetTaskDoneStep(input) {
407
567
  ...raw,
408
568
  completedAt: new Date().toISOString(),
409
569
  };
570
+ const pathSegments = relativeFromTodo.split(/[/\\]/);
571
+ const isTopLevelParent = pathSegments.length === 1;
572
+ const completedTicketsDir = path$1.join(toDir, 'tickets');
573
+ const hasCompletedTickets = (await listSubdirNames(completedTicketsDir)).length > 0;
574
+ if (await pathExists(toDir)) {
575
+ if (isTopLevelParent && hasCompletedTickets) {
576
+ await mergeTodoUmbrellaParentIntoCompleted({
577
+ fromDir,
578
+ toDir,
579
+ completedDescPath,
580
+ updatedDesc: updated,
581
+ });
582
+ return {
583
+ executable: 'cat',
584
+ args: [completedDescPath],
585
+ };
586
+ }
587
+ console.warn(`[lumpcode/recipes] Cannot move backlog item "${taskName ?? relativeFromTodo}": already exists at ${toDir}`);
588
+ return null;
589
+ }
410
590
  await fs.mkdir(path$1.dirname(toDir), { recursive: true });
411
591
  await fs.rename(fromDir, toDir);
412
592
  await fs.writeFile(completedDescPath, dump(updated));
@@ -543,7 +723,7 @@ function buildStageSteps(stages, stageName) {
543
723
  return normalized;
544
724
  }
545
725
  function backlog(options) {
546
- const { configUrl, backlogItemsDir: backlogItemsDirOverride, stages, parseItem, resolveItem, ...rest } = options;
726
+ const { configUrl, backlogItemsDir: backlogItemsDirOverride, includeUmbrellaParents, stages, parseItem, resolveItem, ...rest } = options;
547
727
  const paths = resolveBacklogPaths(configUrl, {
548
728
  backlogItemsDir: backlogItemsDirOverride,
549
729
  });
@@ -553,6 +733,7 @@ function backlog(options) {
553
733
  getContextListFn: async (input) => {
554
734
  const listFn = folderBacklogContexts({
555
735
  backlogItemsDir: absoluteBacklogItemsDir,
736
+ includeUmbrellaParents,
556
737
  parseItem,
557
738
  async parseContext(item, folderName) {
558
739
  const resolution = await resolveItem({
@@ -741,32 +922,309 @@ Do not take too much time looking for every possible abstraction. Once you found
741
922
  `.trim();
742
923
  }
743
924
 
744
- const FEATURE_BACKLOG_WORKFLOWS = [
745
- 'tdd',
925
+ function featureContextVars(context) {
926
+ return context.variables;
927
+ }
928
+ const defaultReqPrompt = ({ context }) => {
929
+ const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE } = featureContextVars(context);
930
+ return `
931
+ Write a requirements document for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
932
+
933
+ Task name: ${TASK_NAME}
934
+
935
+ Task:
936
+ ${TASK}
937
+
938
+ Save the requirements document to @${REQ_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
939
+
940
+ The requirements document should be self-contained and implementation-ready. Include:
941
+ - Problem statement and motivation
942
+ - Goals and non-goals
943
+ - User stories / use cases
944
+ - Docs updates (if relevant)
945
+ - Proposed behavior and UX (for CLI work, include command syntax where relevant)
946
+ - Technical approach and affected packages or docs
947
+ - Acceptance criteria
948
+
949
+ Do not implement the feature — only create the requirements markdown file.
950
+ Do not wait the user to answer any questions — make the best assumptions and just write the requirements document.
951
+ The requirements document should not contain any testing strategy details.
952
+ `.trim();
953
+ };
954
+ function defaultReqFixPrompt(prevValidateCommandResult) {
955
+ return ({ context }) => {
956
+ const { BACKLOG_ITEM_DIR, REQ_FILE } = featureContextVars(context);
957
+ return `
958
+ The requirements document was not created at @${REQ_FILE}.
959
+
960
+ Create it now at that exact path. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
961
+ Do not implement the feature — only write the requirements markdown file.
962
+ The requirements document should not contain any testing strategy details.
963
+
964
+ Verification output:
965
+ ${prevValidateCommandResult ?? '(no output captured)'}
966
+ `.trim();
967
+ };
968
+ }
969
+ const defaultTestPlanPrompt = ({ context }) => {
970
+ const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
971
+ return `
972
+ Write a test plan for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
973
+
974
+ Task name: ${TASK_NAME}
975
+ Task:
976
+ ${TASK}
977
+
978
+ The requirements for this task are in @${REQ_FILE}. The test plan should match those requirements.
979
+
980
+ Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
981
+
982
+ The test plan should be self-contained and implementation-ready. Include:
983
+ - Test cases
984
+ - Test data
985
+ - Test expectations
986
+ - Test implementation details
987
+ `.trim();
988
+ };
989
+ function defaultTestPlanFixPrompt(prevValidateCommandResult) {
990
+ return ({ context }) => {
991
+ const { BACKLOG_ITEM_DIR, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
992
+ return `
993
+ The test plan was not created at @${TEST_PLAN_FILE}.
994
+
995
+ Create it now at that exact path. Match the requirements in @${REQ_FILE}.
996
+ Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
997
+
998
+ Verification output:
999
+ ${prevValidateCommandResult ?? '(no output captured)'}
1000
+ `.trim();
1001
+ };
1002
+ }
1003
+ const defaultTestImplPrompt = ({ context }) => {
1004
+ const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
1005
+ const planLine = TEST_PLAN_FILE
1006
+ ? `Follow the test plan in @${TEST_PLAN_FILE}.`
1007
+ : 'Write skipped tests from the requirements and your judgement. Do not create a testPlan.md.';
1008
+ return `
1009
+ Write a test implementation for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
1010
+
1011
+ The new tests should be skipped in order to not break the whole test suite.
1012
+
1013
+ Task name: ${TASK_NAME}
1014
+ Task:
1015
+ ${TASK}
1016
+
1017
+ ${planLine}
1018
+ The requirements for this task are in @${REQ_FILE}.
1019
+ `.trim();
1020
+ };
1021
+ const defaultImplPrompt = ({ context }) => {
1022
+ const { REQ_FILE, TEST_PLAN_FILE, WORKFLOW } = featureContextVars(context);
1023
+ const wantsTestImpl = (WORKFLOW ?? '').split(',').includes('testImpl');
1024
+ if (wantsTestImpl) {
1025
+ return `
1026
+ Implement the feature described in @${REQ_FILE}.
1027
+ The tests have already been implemented according to the test plan${TEST_PLAN_FILE ? ` in @${TEST_PLAN_FILE}` : ''}.
1028
+ Unskip all the tests that were skipped in the tests implementation.
1029
+ The implementation should make the tests pass. Do not edit any test file except to unskip them or if absolutely necessary.
1030
+ `.trim();
1031
+ }
1032
+ if (TEST_PLAN_FILE) {
1033
+ return `
1034
+ Implement the feature described in @${REQ_FILE}.
1035
+ Write or update tests to match the test plan in @${TEST_PLAN_FILE}, and make validation pass.
1036
+ Do not edit @${REQ_FILE} unless absolutely necessary.
1037
+ `.trim();
1038
+ }
1039
+ return `
1040
+ Implement the feature described in @${REQ_FILE}.
1041
+ Add or update tests as needed so the suite covers the change, and make validation pass.
1042
+ Do not edit @${REQ_FILE} unless absolutely necessary.
1043
+ `.trim();
1044
+ };
1045
+ const defaultDirectImplPrompt = ({ context }) => {
1046
+ const { BACKLOG_ITEM_DIR, REQ_FILE, TEST_PLAN_FILE } = featureContextVars(context);
1047
+ const extras = [
1048
+ REQ_FILE ? `Use the requirements in @${REQ_FILE}.` : '',
1049
+ TEST_PLAN_FILE ? `Use the test plan in @${TEST_PLAN_FILE} for tests.` : '',
1050
+ ]
1051
+ .filter(Boolean)
1052
+ .join('\n');
1053
+ return `
1054
+ Implement the feature described in @${BACKLOG_ITEM_DIR}/desc.yml.
1055
+ ${extras}
1056
+ Add tests as needed so the suite covers the change, and make validation pass.
1057
+ `.trim();
1058
+ };
1059
+
1060
+ const FEATURE_BACKLOG_WORKFLOW_STAGES = [
1061
+ 'req',
1062
+ 'testPlan',
1063
+ 'testImpl',
1064
+ 'impl',
746
1065
  'directImpl',
747
- 'manual',
748
1066
  ];
749
- const RESERVED_NAME_SUFFIXES = ['_req', '_testPlan', '_tests_impl'];
1067
+ const DEFAULT_FEATURE_BACKLOG_WORKFLOW = [
1068
+ 'req',
1069
+ 'testPlan',
1070
+ 'testImpl',
1071
+ ];
1072
+ const DEFAULT_PRIMARY_DISCOVERY_BRANCH = 'dev';
1073
+ const DEFAULT_ITEM_DISCOVERY_BRANCH_PREFIX = 'feature';
1074
+ const FEATURE_BACKLOG_RESERVED_NAME_SUFFIXES = [
1075
+ '_req',
1076
+ '_testPlan',
1077
+ '_testImpl',
1078
+ ];
1079
+ const WORKFLOW_PREFIX_ORDER = ['req', 'testPlan', 'testImpl'];
1080
+
1081
+ function assertDiscoveryToken(value, field) {
1082
+ if (value.length === 0 || /\s/.test(value) || value.includes('*') || value.endsWith('/')) {
1083
+ throw new Error(`featureBacklog ${field} must be a non-empty exact name without whitespace, *, or a trailing /`);
1084
+ }
1085
+ }
1086
+ function resolveFeatureBacklogDiscoveryOptions(options) {
1087
+ const primaryDiscoveryBranch = options.primaryDiscoveryBranch ?? DEFAULT_PRIMARY_DISCOVERY_BRANCH;
1088
+ const itemDiscoveryBranchPrefix = options.itemDiscoveryBranchPrefix ?? DEFAULT_ITEM_DISCOVERY_BRANCH_PREFIX;
1089
+ assertDiscoveryToken(primaryDiscoveryBranch, 'primaryDiscoveryBranch');
1090
+ assertDiscoveryToken(itemDiscoveryBranchPrefix, 'itemDiscoveryBranchPrefix');
1091
+ return { primaryDiscoveryBranch, itemDiscoveryBranchPrefix };
1092
+ }
1093
+ function campaignBranchPrefix(itemDiscoveryBranchPrefix) {
1094
+ return `${itemDiscoveryBranchPrefix}/`;
1095
+ }
1096
+ function workflowHasCampaignStages(workflow) {
1097
+ return workflow.includes('testPlan') || workflow.includes('testImpl');
1098
+ }
1099
+ function classifyDiscoveryScan(discoveryBranch, primaryDiscoveryBranch, itemDiscoveryBranchPrefix) {
1100
+ if (discoveryBranch === primaryDiscoveryBranch) {
1101
+ return 'primary';
1102
+ }
1103
+ if (discoveryBranch.startsWith(campaignBranchPrefix(itemDiscoveryBranchPrefix))) {
1104
+ return 'itemCampaign';
1105
+ }
1106
+ return 'unmatched';
1107
+ }
1108
+ function itemNameFromCampaignBranch(discoveryBranch, itemDiscoveryBranchPrefix) {
1109
+ return discoveryBranch.slice(campaignBranchPrefix(itemDiscoveryBranchPrefix).length);
1110
+ }
1111
+ function itemIsEligibleForDiscoveryScan(input) {
1112
+ const kind = classifyDiscoveryScan(input.discoveryBranch, input.primaryDiscoveryBranch, input.itemDiscoveryBranchPrefix);
1113
+ switch (kind) {
1114
+ case 'primary':
1115
+ return input.parentName === undefined && !workflowHasCampaignStages(input.workflow);
1116
+ case 'itemCampaign': {
1117
+ const campaignItemName = itemNameFromCampaignBranch(input.discoveryBranch, input.itemDiscoveryBranchPrefix);
1118
+ return (input.parentName ?? input.itemName) === campaignItemName;
1119
+ }
1120
+ case 'unmatched':
1121
+ return false;
1122
+ default: {
1123
+ const _exhaustive = kind;
1124
+ return _exhaustive;
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ let warnedManualReq = false;
1130
+ function warnManualReqDeprecated() {
1131
+ if (warnedManualReq) {
1132
+ return;
1133
+ }
1134
+ warnedManualReq = true;
1135
+ console.warn('[lumpcode/recipes] featureBacklog: desc.yml field "manualReq" is deprecated and ignored. ' +
1136
+ 'Omit "req" from workflow to wait for a human requirements file, or set manual: true to skip the item.');
1137
+ }
750
1138
  function assertValidFeatureItemName(name) {
751
- for (const suffix of RESERVED_NAME_SUFFIXES) {
1139
+ for (const suffix of FEATURE_BACKLOG_RESERVED_NAME_SUFFIXES) {
752
1140
  if (name.endsWith(suffix)) {
753
1141
  throw new Error(`Backlog item name must not end with reserved suffix ${suffix}: ${name}`);
754
1142
  }
755
1143
  }
756
1144
  }
1145
+ function isWorkflowStage(value) {
1146
+ return (typeof value === 'string' &&
1147
+ FEATURE_BACKLOG_WORKFLOW_STAGES.includes(value));
1148
+ }
1149
+ function normalizeWorkflow(stages) {
1150
+ const unique = new Set(stages);
1151
+ const normalized = [];
1152
+ for (const stage of WORKFLOW_PREFIX_ORDER) {
1153
+ if (unique.has(stage)) {
1154
+ normalized.push(stage);
1155
+ }
1156
+ }
1157
+ if (unique.has('directImpl')) {
1158
+ normalized.push('directImpl');
1159
+ }
1160
+ else if (unique.has('impl')) {
1161
+ normalized.push('impl');
1162
+ }
1163
+ return normalized;
1164
+ }
1165
+ function parseFeatureWorkflow(itemName, raw) {
1166
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1167
+ return undefined;
1168
+ }
1169
+ const record = raw;
1170
+ if ('manualReq' in record) {
1171
+ warnManualReqDeprecated();
1172
+ }
1173
+ if (record.workflow === undefined) {
1174
+ return undefined;
1175
+ }
1176
+ if (!Array.isArray(record.workflow)) {
1177
+ throw new Error(`Backlog item "${itemName}" field "workflow" must be an array of stages: ${FEATURE_BACKLOG_WORKFLOW_STAGES.join(', ')}`);
1178
+ }
1179
+ if (record.workflow.length !== new Set(record.workflow).size) {
1180
+ throw new Error(`Backlog item "${itemName}" field "workflow" must not contain duplicate stages`);
1181
+ }
1182
+ const stages = [];
1183
+ for (const entry of record.workflow) {
1184
+ if (!isWorkflowStage(entry)) {
1185
+ throw new Error(`Backlog item "${itemName}" field "workflow" contains unknown stage ${JSON.stringify(entry)}`);
1186
+ }
1187
+ stages.push(entry);
1188
+ }
1189
+ return normalizeWorkflow(stages);
1190
+ }
1191
+ function parseManual(itemName, raw) {
1192
+ if (raw.manual === undefined) {
1193
+ return undefined;
1194
+ }
1195
+ if (typeof raw.manual !== 'boolean') {
1196
+ throw new Error(`Backlog item "${itemName}" field "manual" must be a boolean`);
1197
+ }
1198
+ return raw.manual === true ? true : undefined;
1199
+ }
1200
+ function resolveTerminal(workflow) {
1201
+ if (workflow.includes('directImpl')) {
1202
+ return 'directImpl';
1203
+ }
1204
+ return 'impl';
1205
+ }
1206
+ function parentNameFromTodoRelativeDir(todoRelativeDir) {
1207
+ const parts = todoRelativeDir.split('/');
1208
+ if (parts.length === 3 && parts[1] === 'tickets') {
1209
+ return parts[0];
1210
+ }
1211
+ return undefined;
1212
+ }
1213
+
757
1214
  function featureItemContextBaseName(item) {
758
1215
  return item.parentName ? `${item.parentName}-${item.name}` : item.name;
759
1216
  }
760
1217
  function featureContextName(itemName, stage) {
761
1218
  switch (stage) {
762
- case 'makeReq':
1219
+ case 'req':
763
1220
  return `${itemName}_req`;
764
- case 'makeTestPlan':
1221
+ case 'testPlan':
765
1222
  return `${itemName}_testPlan`;
766
1223
  case 'testImpl':
767
- return `${itemName}_tests_impl`;
768
- case 'implementation':
1224
+ return `${itemName}_testImpl`;
1225
+ case 'impl':
769
1226
  case 'directImpl':
1227
+ case 'completion':
770
1228
  return itemName;
771
1229
  default: {
772
1230
  const _exhaustive = stage;
@@ -774,34 +1232,13 @@ function featureContextName(itemName, stage) {
774
1232
  }
775
1233
  }
776
1234
  }
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
1235
  async function resolveItemWorkflow(input) {
799
1236
  const { item, paths, projectRoot } = input;
800
1237
  if (item.workflow !== undefined) {
801
1238
  return item.workflow;
802
1239
  }
803
1240
  if (item.parentName === undefined) {
804
- return 'tdd';
1241
+ return DEFAULT_FEATURE_BACKLOG_WORKFLOW;
805
1242
  }
806
1243
  const parentDescPath = path$1.join(projectRoot, paths.backlogItemsDir, 'todo', item.parentName, 'desc.yml');
807
1244
  let rawText;
@@ -811,41 +1248,58 @@ async function resolveItemWorkflow(input) {
811
1248
  catch (error) {
812
1249
  const err = error;
813
1250
  if (err.code === 'ENOENT') {
814
- return 'tdd';
1251
+ return DEFAULT_FEATURE_BACKLOG_WORKFLOW;
815
1252
  }
816
1253
  throw error;
817
1254
  }
818
- return parseFeatureWorkflow(item.parentName, load(rawText)) ?? 'tdd';
1255
+ return parseFeatureWorkflow(item.parentName, load(rawText)) ?? DEFAULT_FEATURE_BACKLOG_WORKFLOW;
819
1256
  }
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;
1257
+ function artifactVariables(input) {
1258
+ const variables = {
1259
+ WORKFLOW: input.workflow.join(','),
1260
+ };
1261
+ if (input.hasReq || input.stage === 'req') {
1262
+ variables.REQ_FILE = input.reqFilePath;
829
1263
  }
830
- if (!discoveryBranch.startsWith('feature/')) {
831
- return false;
1264
+ if (input.hasTestPlan || input.stage === 'testPlan') {
1265
+ variables.TEST_PLAN_FILE = input.testPlanFilePath;
832
1266
  }
833
- const key = discoveryBranch.slice('feature/'.length);
834
- return (parentName ?? itemName) === key;
1267
+ return variables;
835
1268
  }
836
1269
  async function resolveFeatureBacklogItem(input) {
837
- const { item, paths, projectRoot, discoveryBranch } = input;
1270
+ const { item, paths, projectRoot, discoveryBranch, primaryDiscoveryBranch, itemDiscoveryBranchPrefix, } = input;
1271
+ if (item.parentName === undefined) {
1272
+ const todoDir = path$1.join(projectRoot, paths.backlogItemsDir, 'todo');
1273
+ const ticketNames = await listUmbrellaTicketNames({
1274
+ todoDir,
1275
+ parentName: item.name,
1276
+ });
1277
+ if (ticketNames.length > 0) {
1278
+ if (item.completedAt) {
1279
+ return { ignored: true };
1280
+ }
1281
+ if (classifyDiscoveryScan(discoveryBranch, primaryDiscoveryBranch, itemDiscoveryBranchPrefix) !== 'itemCampaign' ||
1282
+ itemNameFromCampaignBranch(discoveryBranch, itemDiscoveryBranchPrefix) !== item.name) {
1283
+ return { ignored: true };
1284
+ }
1285
+ return {
1286
+ stage: 'completion',
1287
+ contextName: item.name,
1288
+ additionalDependsOnContexts: ticketNames.map((ticketName) => `${item.name}-${ticketName}`),
1289
+ };
1290
+ }
1291
+ }
838
1292
  const contextBaseName = featureItemContextBaseName(item);
839
1293
  const workflow = await resolveItemWorkflow({ item, paths, projectRoot });
840
- if (workflow === 'manual') {
841
- return { ignored: true };
842
- }
843
- if (!!item.completedAt ||
844
- !itemMatchesDiscoveryBranch({
1294
+ if (item.manual === true ||
1295
+ !!item.completedAt ||
1296
+ !itemIsEligibleForDiscoveryScan({
845
1297
  itemName: item.name,
846
1298
  parentName: item.parentName,
847
1299
  discoveryBranch,
848
1300
  workflow,
1301
+ primaryDiscoveryBranch,
1302
+ itemDiscoveryBranchPrefix,
849
1303
  })) {
850
1304
  return { ignored: true };
851
1305
  }
@@ -853,77 +1307,75 @@ async function resolveFeatureBacklogItem(input) {
853
1307
  const reqFilePath = path$1.join(itemDir, 'requirements.md');
854
1308
  const testPlanFilePath = path$1.join(itemDir, 'testPlan.md');
855
1309
  const hasReq = await pathExists(path$1.join(projectRoot, reqFilePath));
856
- if (!hasReq) {
857
- if (item.manualReq === true) {
858
- return { ignored: true };
859
- }
860
- return {
861
- stage: 'makeReq',
862
- contextName: featureContextName(contextBaseName, 'makeReq'),
863
- variables: { REQ_FILE: reqFilePath },
864
- };
1310
+ const hasTestPlan = await pathExists(path$1.join(projectRoot, testPlanFilePath));
1311
+ const wants = (stage) => workflow.includes(stage);
1312
+ const terminal = resolveTerminal(workflow);
1313
+ const resolveStage = (stage) => ({
1314
+ stage,
1315
+ contextName: featureContextName(contextBaseName, stage),
1316
+ variables: artifactVariables({
1317
+ stage,
1318
+ workflow,
1319
+ reqFilePath,
1320
+ testPlanFilePath,
1321
+ hasReq,
1322
+ hasTestPlan,
1323
+ }),
1324
+ });
1325
+ if (wants('req') && !hasReq) {
1326
+ return resolveStage('req');
865
1327
  }
866
- if (workflow === 'directImpl') {
867
- return {
868
- stage: 'directImpl',
869
- contextName: featureContextName(contextBaseName, 'directImpl'),
870
- variables: { REQ_FILE: reqFilePath },
871
- };
1328
+ if (!hasReq && (wants('testPlan') || wants('testImpl'))) {
1329
+ return { ignored: true };
872
1330
  }
873
- const hasTestPlan = await pathExists(path$1.join(projectRoot, testPlanFilePath));
874
- if (!hasTestPlan) {
875
- return {
876
- stage: 'makeTestPlan',
877
- contextName: featureContextName(contextBaseName, 'makeTestPlan'),
878
- variables: {
879
- REQ_FILE: reqFilePath,
880
- TEST_PLAN_FILE: testPlanFilePath,
881
- },
882
- };
1331
+ if (wants('testPlan') && !hasTestPlan) {
1332
+ return resolveStage('testPlan');
883
1333
  }
884
- const testsImplContextName = featureContextName(contextBaseName, 'testImpl');
885
- const testsImplStatus = await getContextStatus({
886
- projectRoot,
887
- contextName: testsImplContextName,
888
- lumpName: paths.lumpName,
889
- baseBranch: discoveryBranch,
890
- });
891
- if (testsImplStatus === 'finished') {
892
- return {
893
- stage: 'implementation',
894
- contextName: featureContextName(contextBaseName, 'implementation'),
895
- variables: {
896
- REQ_FILE: reqFilePath,
897
- TEST_PLAN_FILE: testPlanFilePath,
898
- },
899
- };
1334
+ if (wants('testImpl')) {
1335
+ const testsImplContextName = featureContextName(contextBaseName, 'testImpl');
1336
+ const testsImplStatus = await getContextStatus({
1337
+ projectRoot,
1338
+ contextName: testsImplContextName,
1339
+ lumpName: paths.lumpName,
1340
+ baseBranch: discoveryBranch,
1341
+ });
1342
+ if (testsImplStatus === 'branchPushed') {
1343
+ return { ignored: true };
1344
+ }
1345
+ if (testsImplStatus !== 'finished') {
1346
+ return resolveStage('testImpl');
1347
+ }
1348
+ }
1349
+ if (terminal === 'directImpl') {
1350
+ return resolveStage('directImpl');
900
1351
  }
901
- if (testsImplStatus === 'branchPushed') {
1352
+ if (!hasReq) {
902
1353
  return { ignored: true };
903
1354
  }
904
- return {
905
- stage: 'testImpl',
906
- contextName: testsImplContextName,
907
- variables: {
908
- REQ_FILE: reqFilePath,
909
- TEST_PLAN_FILE: testPlanFilePath,
910
- },
911
- };
1355
+ return resolveStage('impl');
912
1356
  }
1357
+
913
1358
  const featureBacklog = defineRecipe(function featureBacklog(options) {
914
- const { configUrl, implValidateCommand, backlogItemsDir, ...rest } = options;
1359
+ const { configUrl, implValidateCommand, backlogItemsDir, primaryDiscoveryBranch, itemDiscoveryBranchPrefix, promptFns, ...rest } = options;
1360
+ const discovery = resolveFeatureBacklogDiscoveryOptions({
1361
+ primaryDiscoveryBranch,
1362
+ itemDiscoveryBranchPrefix,
1363
+ });
915
1364
  const projectRoot = projectRootFromConfigUrl(configUrl);
916
1365
  const runImplValidation = resolveImplValidateCommand(implValidateCommand ??
917
1366
  'echo "No implementation validation command provided. I say, trust but verify, but well..."');
918
1367
  return backlog({
919
1368
  configUrl,
920
1369
  backlogItemsDir,
1370
+ includeUmbrellaParents: true,
1371
+ ...rest,
1372
+ discoveryBranches: [
1373
+ discovery.primaryDiscoveryBranch,
1374
+ `${discovery.itemDiscoveryBranchPrefix}/*`,
1375
+ ],
921
1376
  parseItem(baseItem, folderName, raw) {
922
1377
  assertValidFeatureItemName(baseItem.name);
923
1378
  const record = raw;
924
- if (record.manualReq !== undefined && typeof record.manualReq !== 'boolean') {
925
- throw new Error(`Backlog item "${baseItem.name}" field "manualReq" must be a boolean`);
926
- }
927
1379
  const parentName = parentNameFromTodoRelativeDir(folderName);
928
1380
  return {
929
1381
  ...baseItem,
@@ -932,7 +1384,7 @@ const featureBacklog = defineRecipe(function featureBacklog(options) {
932
1384
  dependsOn: parentName
933
1385
  ? baseItem.dependsOn?.map((dep) => `${parentName}-${dep}`)
934
1386
  : baseItem.dependsOn,
935
- manualReq: record.manualReq === true ? true : undefined,
1387
+ manual: parseManual(baseItem.name, record),
936
1388
  workflow: parseFeatureWorkflow(baseItem.name, raw),
937
1389
  };
938
1390
  },
@@ -942,177 +1394,55 @@ const featureBacklog = defineRecipe(function featureBacklog(options) {
942
1394
  paths,
943
1395
  projectRoot,
944
1396
  discoveryBranch,
1397
+ primaryDiscoveryBranch: discovery.primaryDiscoveryBranch,
1398
+ itemDiscoveryBranchPrefix: discovery.itemDiscoveryBranchPrefix,
945
1399
  });
946
1400
  },
947
1401
  stages: {
948
- makeReq: {
1402
+ req: {
949
1403
  completion: 'keepPending',
950
1404
  steps: retryUntilGreen({
951
- steps: [
952
- {
953
- promptFn({ context: ctx }) {
954
- const vars = ctx.variables;
955
- const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE } = vars;
956
- return `
957
- Write a requirements document for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
958
-
959
- Task name: ${TASK_NAME}
960
-
961
- Task:
962
- ${TASK}
963
-
964
- Save the requirements document to @${REQ_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
965
-
966
- The requirements document should be self-contained and implementation-ready. Include:
967
- - Problem statement and motivation
968
- - Goals and non-goals
969
- - User stories / use cases
970
- - Docs updates (if relevant)
971
- - Proposed behavior and UX (for CLI work, include command syntax where relevant)
972
- - Technical approach and affected packages or docs
973
- - Acceptance criteria
974
-
975
- Do not implement the feature — only create the requirements markdown file.
976
- Do not wait the user to answer any questions — make the best assumptions and just write the requirements document.
977
- The requirements document should not contain any testing strategy details.
978
- `.trim();
979
- },
980
- },
981
- ],
1405
+ steps: [{ promptFn: promptFns?.req ?? defaultReqPrompt }],
982
1406
  validationCommandFn: requireArtifactStep('REQ_FILE'),
983
1407
  fixSteps: ({ prevValidateCommandResult }) => [
984
- {
985
- promptFn({ context: ctx }) {
986
- const vars = ctx.variables;
987
- const { BACKLOG_ITEM_DIR, REQ_FILE } = vars;
988
- return `
989
- The requirements document was not created at @${REQ_FILE}.
990
-
991
- Create it now at that exact path. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml.
992
- Do not implement the feature — only write the requirements markdown file.
993
- The requirements document should not contain any testing strategy details.
994
-
995
- Verification output:
996
- ${prevValidateCommandResult ?? '(no output captured)'}
997
- `.trim();
998
- },
999
- },
1408
+ { promptFn: defaultReqFixPrompt(prevValidateCommandResult) },
1000
1409
  ],
1001
1410
  }),
1002
1411
  },
1003
- makeTestPlan: {
1412
+ testPlan: {
1004
1413
  completion: 'keepPending',
1005
1414
  steps: retryUntilGreen({
1006
- steps: [
1007
- {
1008
- promptFn({ context: ctx }) {
1009
- const vars = ctx.variables;
1010
- const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = vars;
1011
- return `
1012
- Write a test plan for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
1013
-
1014
- Task name: ${TASK_NAME}
1015
- Task:
1016
- ${TASK}
1017
-
1018
- The requirements for this task are in @${REQ_FILE}. The test plan should match those requirements.
1019
-
1020
- Save the test plan to @${TEST_PLAN_FILE}. Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
1021
-
1022
- The test plan should be self-contained and implementation-ready. Include:
1023
- - Test cases
1024
- - Test data
1025
- - Test expectations
1026
- - Test implementation details
1027
- `.trim();
1028
- },
1029
- },
1030
- ],
1415
+ steps: [{ promptFn: promptFns?.testPlan ?? defaultTestPlanPrompt }],
1031
1416
  validationCommandFn: requireArtifactStep('TEST_PLAN_FILE'),
1032
1417
  fixSteps: ({ prevValidateCommandResult }) => [
1033
- {
1034
- promptFn({ context: ctx }) {
1035
- const vars = ctx.variables;
1036
- const { BACKLOG_ITEM_DIR, REQ_FILE, TEST_PLAN_FILE } = vars;
1037
- return `
1038
- The test plan was not created at @${TEST_PLAN_FILE}.
1039
-
1040
- Create it now at that exact path. Match the requirements in @${REQ_FILE}.
1041
- Do not edit @${BACKLOG_ITEM_DIR}/desc.yml nor @${REQ_FILE}.
1042
-
1043
- Verification output:
1044
- ${prevValidateCommandResult ?? '(no output captured)'}
1045
- `.trim();
1046
- },
1047
- },
1418
+ { promptFn: defaultTestPlanFixPrompt(prevValidateCommandResult) },
1048
1419
  ],
1049
1420
  }),
1050
1421
  },
1051
1422
  testImpl: {
1052
1423
  completion: 'keepPending',
1053
- steps: [
1054
- {
1055
- promptFn({ context: ctx }) {
1056
- const vars = ctx.variables;
1057
- const { BACKLOG_ITEM_DIR, TASK_NAME, TASK, REQ_FILE, TEST_PLAN_FILE } = vars;
1058
- return `
1059
- Write a test implementation for the following backlog item from @${BACKLOG_ITEM_DIR}/desc.yml.
1060
-
1061
- The new tests should be skipped in order to not break the whole test suite.
1062
-
1063
- Task name: ${TASK_NAME}
1064
- Task:
1065
- ${TASK}
1066
-
1067
- Follow the test plan in @${TEST_PLAN_FILE}.
1068
- The requirements for this task are in @${REQ_FILE}.
1069
- `.trim();
1070
- },
1071
- },
1072
- ],
1424
+ steps: [{ promptFn: promptFns?.testImpl ?? defaultTestImplPrompt }],
1073
1425
  },
1074
- implementation: {
1426
+ impl: {
1075
1427
  completion: 'moveToDone',
1076
1428
  steps: retryUntilGreen({
1077
- steps: [
1078
- {
1079
- promptFn({ context: ctx }) {
1080
- const vars = ctx.variables;
1081
- const { REQ_FILE, TEST_PLAN_FILE } = vars;
1082
- return `
1083
- Implement the feature described in @${REQ_FILE}.
1084
- The tests have already been implemented according to the test plan in @${TEST_PLAN_FILE}.
1085
- Unskip all the tests that were skipped in the tests implementation.
1086
- The implementation should make the tests pass. Do not edit any test file except to unskip them or if absolutely necessary.
1087
- `.trim();
1088
- },
1089
- },
1090
- ],
1429
+ steps: [{ promptFn: promptFns?.impl ?? defaultImplPrompt }],
1091
1430
  validationCommandFn: runImplValidation,
1092
1431
  }),
1093
1432
  },
1094
1433
  directImpl: {
1095
1434
  completion: 'moveToDone',
1096
1435
  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
- ],
1436
+ steps: [{ promptFn: promptFns?.directImpl ?? defaultDirectImplPrompt }],
1110
1437
  validationCommandFn: runImplValidation,
1111
1438
  }),
1112
1439
  },
1440
+ completion: {
1441
+ completion: 'moveToDone',
1442
+ steps: [],
1443
+ },
1113
1444
  },
1114
- ...rest,
1115
1445
  });
1116
1446
  });
1117
1447
 
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 };
1448
+ export { BACKLOG_ITEMS_DIR_VAR, BACKLOG_ITEM_DIR_VAR, BACKLOG_STAGE_VAR, BACKLOG_TASK_NAME_VAR, BACKLOG_TASK_VAR, DEFAULT_FEATURE_BACKLOG_WORKFLOW, DEFAULT_ITEM_DISCOVERY_BRANCH_PREFIX, DEFAULT_PRIMARY_DISCOVERY_BRANCH, FEATURE_BACKLOG_RESERVED_NAME_SUFFIXES, FEATURE_BACKLOG_WORKFLOW_STAGES, OPEN_PR_PROVIDERS, abstractionBacklog, abstractionFinder, backlog, backlogRecipe, defaultDirectImplPrompt, defaultImplPrompt, defaultReqFixPrompt, defaultReqPrompt, defaultTestImplPrompt, defaultTestPlanFixPrompt, defaultTestPlanPrompt, defineRecipe, ephemeralContextListFn, featureBacklog, folderBacklogContexts, folderSetTaskDoneStep, getRecursiveSteps, listTodoRelativeDirs, listUmbrellaTicketNames, lumpPathAndName, normalizeMaybePromGetter, openPrPostTeardown, parseFeatureWorkflow, projectRootFromConfigUrl, requireArtifactStep, resolveBacklogPaths, resolveFeatureBacklogDiscoveryOptions, resolveFeatureBacklogItem, resolveImplValidateCommand, retryUntilGreen, setTaskDoneStep, shellCommand, validateBaseBacklogItem, ymlBacklogContexts };