agile-context-engineering 0.2.2 → 0.5.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.
Files changed (146) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +27 -18
  4. package/agents/ace-product-owner.md +1 -1
  5. package/agents/ace-technical-application-architect.md +28 -0
  6. package/agents/ace-wiki-mapper.md +144 -29
  7. package/bin/install.js +67 -63
  8. package/hooks/ace-check-update.js +17 -9
  9. package/package.json +7 -5
  10. package/shared/lib/ace-core.js +308 -0
  11. package/shared/lib/ace-core.test.js +308 -0
  12. package/shared/lib/ace-github.js +753 -0
  13. package/shared/lib/ace-story.js +400 -0
  14. package/shared/lib/ace-story.test.js +250 -0
  15. package/{agile-context-engineering → shared}/utils/ui-formatting.md +299 -299
  16. package/skills/execute-story/SKILL.md +110 -0
  17. package/skills/execute-story/script.js +305 -0
  18. package/skills/execute-story/script.test.js +261 -0
  19. package/skills/execute-story/walkthrough-template.xml +255 -0
  20. package/{agile-context-engineering/workflows/execute-story.xml → skills/execute-story/workflow.xml} +83 -9
  21. package/skills/help/SKILL.md +69 -0
  22. package/skills/help/script.js +318 -0
  23. package/skills/help/script.test.js +183 -0
  24. package/{agile-context-engineering/workflows/help.xml → skills/help/workflow.xml} +8 -8
  25. package/skills/init-coding-standards/SKILL.md +72 -0
  26. package/{agile-context-engineering/templates/wiki/coding-standards.xml → skills/init-coding-standards/coding-standards-template.xml} +38 -0
  27. package/skills/init-coding-standards/script.js +59 -0
  28. package/skills/init-coding-standards/script.test.js +70 -0
  29. package/{agile-context-engineering/workflows/init-coding-standards.xml → skills/init-coding-standards/workflow.xml} +4 -9
  30. package/skills/map-cross-cutting/SKILL.md +89 -0
  31. package/skills/map-cross-cutting/workflow.xml +330 -0
  32. package/skills/map-guide/SKILL.md +89 -0
  33. package/skills/map-guide/workflow.xml +320 -0
  34. package/skills/map-pattern/SKILL.md +89 -0
  35. package/skills/map-pattern/workflow.xml +331 -0
  36. package/skills/map-story/SKILL.md +127 -0
  37. package/skills/map-story/templates/guide.xml +137 -0
  38. package/skills/map-story/templates/pattern.xml +159 -0
  39. package/skills/map-story/templates/system-cross-cutting.xml +197 -0
  40. package/skills/map-story/templates/walkthrough.xml +255 -0
  41. package/{agile-context-engineering/workflows/map-story.xml → skills/map-story/workflow.xml} +258 -9
  42. package/skills/map-subsystem/SKILL.md +111 -0
  43. package/skills/map-subsystem/script.js +60 -0
  44. package/skills/map-subsystem/script.test.js +68 -0
  45. package/skills/map-subsystem/templates/decizions.xml +115 -0
  46. package/skills/map-subsystem/templates/guide.xml +137 -0
  47. package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/module-discovery.xml +3 -3
  48. package/skills/map-subsystem/templates/pattern.xml +159 -0
  49. package/skills/map-subsystem/templates/system-cross-cutting.xml +197 -0
  50. package/skills/map-subsystem/templates/system.xml +381 -0
  51. package/skills/map-subsystem/templates/walkthrough.xml +255 -0
  52. package/{agile-context-engineering/workflows/map-subsystem.xml → skills/map-subsystem/workflow.xml} +17 -21
  53. package/skills/map-sys-doc/SKILL.md +90 -0
  54. package/skills/map-sys-doc/system.xml +381 -0
  55. package/skills/map-sys-doc/workflow.xml +336 -0
  56. package/skills/map-system/SKILL.md +85 -0
  57. package/skills/map-system/script.js +84 -0
  58. package/skills/map-system/script.test.js +73 -0
  59. package/skills/map-system/templates/wiki-readme.xml +297 -0
  60. package/{agile-context-engineering/workflows/map-system.xml → skills/map-system/workflow.xml} +11 -16
  61. package/skills/map-walkthrough/SKILL.md +92 -0
  62. package/skills/map-walkthrough/walkthrough.xml +255 -0
  63. package/skills/map-walkthrough/workflow.xml +457 -0
  64. package/skills/plan-backlog/SKILL.md +75 -0
  65. package/skills/plan-backlog/script.js +136 -0
  66. package/skills/plan-backlog/script.test.js +83 -0
  67. package/{agile-context-engineering/workflows/plan-backlog.xml → skills/plan-backlog/workflow.xml} +13 -21
  68. package/skills/plan-feature/SKILL.md +76 -0
  69. package/skills/plan-feature/script.js +148 -0
  70. package/skills/plan-feature/script.test.js +80 -0
  71. package/{agile-context-engineering/workflows/plan-feature.xml → skills/plan-feature/workflow.xml} +21 -29
  72. package/skills/plan-product-vision/SKILL.md +75 -0
  73. package/skills/plan-product-vision/script.js +60 -0
  74. package/skills/plan-product-vision/script.test.js +69 -0
  75. package/{agile-context-engineering/workflows/plan-product-vision.xml → skills/plan-product-vision/workflow.xml} +4 -9
  76. package/skills/plan-story/SKILL.md +116 -0
  77. package/skills/plan-story/script.js +326 -0
  78. package/skills/plan-story/script.test.js +240 -0
  79. package/skills/plan-story/story-template.xml +451 -0
  80. package/{agile-context-engineering/workflows/plan-story.xml → skills/plan-story/workflow.xml} +1285 -909
  81. package/skills/research-external-solution/SKILL.md +107 -0
  82. package/skills/research-external-solution/script.js +238 -0
  83. package/skills/research-external-solution/script.test.js +134 -0
  84. package/{agile-context-engineering/workflows/research-external-solution.xml → skills/research-external-solution/workflow.xml} +4 -6
  85. package/skills/research-integration-solution/SKILL.md +98 -0
  86. package/{agile-context-engineering/templates/product/story-integration-solution.xml → skills/research-integration-solution/integration-solution-template.xml} +1 -0
  87. package/skills/research-integration-solution/script.js +231 -0
  88. package/skills/research-integration-solution/script.test.js +134 -0
  89. package/{agile-context-engineering/workflows/research-integration-solution.xml → skills/research-integration-solution/workflow.xml} +4 -5
  90. package/skills/research-story-wiki/SKILL.md +92 -0
  91. package/skills/research-story-wiki/script.js +231 -0
  92. package/skills/research-story-wiki/script.test.js +138 -0
  93. package/{agile-context-engineering/templates/product/story-wiki.xml → skills/research-story-wiki/story-wiki-template.xml} +4 -0
  94. package/{agile-context-engineering/workflows/research-story-wiki.xml → skills/research-story-wiki/workflow.xml} +5 -6
  95. package/skills/research-technical-solution/SKILL.md +103 -0
  96. package/skills/research-technical-solution/script.js +231 -0
  97. package/skills/research-technical-solution/script.test.js +134 -0
  98. package/{agile-context-engineering/workflows/research-technical-solution.xml → skills/research-technical-solution/workflow.xml} +4 -5
  99. package/skills/review-story/SKILL.md +100 -0
  100. package/skills/review-story/script.js +257 -0
  101. package/skills/review-story/script.test.js +169 -0
  102. package/skills/review-story/story-template.xml +451 -0
  103. package/{agile-context-engineering/workflows/review-story.xml → skills/review-story/workflow.xml} +1 -3
  104. package/skills/update/SKILL.md +53 -0
  105. package/{agile-context-engineering/workflows/update.xml → skills/update/workflow.xml} +237 -207
  106. package/agile-context-engineering/src/ace-tools.js +0 -2881
  107. package/agile-context-engineering/src/ace-tools.test.js +0 -1089
  108. package/agile-context-engineering/templates/_command.md +0 -54
  109. package/agile-context-engineering/templates/_workflow.xml +0 -17
  110. package/agile-context-engineering/templates/config.json +0 -0
  111. package/agile-context-engineering/templates/product/integration-solution.xml +0 -0
  112. package/agile-context-engineering/templates/wiki/wiki-readme.xml +0 -276
  113. package/commands/ace/execute-story.md +0 -137
  114. package/commands/ace/help.md +0 -93
  115. package/commands/ace/init-coding-standards.md +0 -83
  116. package/commands/ace/map-story.md +0 -156
  117. package/commands/ace/map-subsystem.md +0 -138
  118. package/commands/ace/map-system.md +0 -92
  119. package/commands/ace/plan-backlog.md +0 -83
  120. package/commands/ace/plan-feature.md +0 -89
  121. package/commands/ace/plan-product-vision.md +0 -81
  122. package/commands/ace/plan-story.md +0 -145
  123. package/commands/ace/research-external-solution.md +0 -138
  124. package/commands/ace/research-integration-solution.md +0 -135
  125. package/commands/ace/research-story-wiki.md +0 -116
  126. package/commands/ace/research-technical-solution.md +0 -147
  127. package/commands/ace/review-story.md +0 -109
  128. package/commands/ace/update.md +0 -54
  129. /package/{agile-context-engineering → shared}/utils/questioning.xml +0 -0
  130. /package/{agile-context-engineering/templates/product/story.xml → skills/execute-story/story-template.xml} +0 -0
  131. /package/{agile-context-engineering/templates/wiki → skills/map-cross-cutting}/system-cross-cutting.xml +0 -0
  132. /package/{agile-context-engineering/templates/wiki → skills/map-guide}/guide.xml +0 -0
  133. /package/{agile-context-engineering/templates/wiki → skills/map-pattern}/pattern.xml +0 -0
  134. /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/decizions.xml +0 -0
  135. /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/system.xml +0 -0
  136. /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/tech-debt-index.xml +0 -0
  137. /package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-architecture.xml +0 -0
  138. /package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-structure.xml +0 -0
  139. /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-architecture.xml +0 -0
  140. /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-structure.xml +0 -0
  141. /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/testing-framework.xml +0 -0
  142. /package/{agile-context-engineering/templates/product/product-backlog.xml → skills/plan-backlog/product-backlog-template.xml} +0 -0
  143. /package/{agile-context-engineering/templates/product/feature.xml → skills/plan-feature/feature-template.xml} +0 -0
  144. /package/{agile-context-engineering/templates/product/product-vision.xml → skills/plan-product-vision/product-vision-template.xml} +0 -0
  145. /package/{agile-context-engineering/templates/product/external-solution.xml → skills/research-external-solution/external-solution-template.xml} +0 -0
  146. /package/{agile-context-engineering/templates/product/story-technical-solution.xml → skills/research-technical-solution/technical-solution-template.xml} +0 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * ACE Story — Story metadata extraction, path computation, and state management.
3
+ *
4
+ * Extracted from ace-tools.js monolith. Contains: story param classification,
5
+ * markdown section extraction, metadata/requirements parsing, wiki reference parsing,
6
+ * path computation, and story state updates across story/feature/backlog files.
7
+ *
8
+ * Usage: const { classifyStoryParam, extractStoryMetadata, updateState } = require('./ace-story');
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const {
14
+ safeReadFile, generateSlug, parseKeyValueArgs, output, error,
15
+ } = require('./ace-core');
16
+
17
+ // ─── Story Param Classification ──────────────────────────────────────────────
18
+
19
+ /**
20
+ * Classify a story parameter as file path, GitHub URL, or issue number.
21
+ * Returns { type, filePath?, repo?, issueNumber?, reason? }
22
+ */
23
+ function classifyStoryParam(param) {
24
+ if (!param) return { type: null, reason: 'No story parameter provided' };
25
+ const trimmed = param.trim();
26
+ if (/^https?:\/\/github\.com\//.test(trimmed)) {
27
+ const match = trimmed.match(/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/);
28
+ if (match) return { type: 'github-url', repo: match[1], issueNumber: parseInt(match[2]) };
29
+ return { type: 'invalid', reason: 'Unrecognized GitHub URL format. Expected: https://github.com/owner/repo/issues/123' };
30
+ }
31
+ if (/^\d+$/.test(trimmed)) {
32
+ return { type: 'issue-number', issueNumber: parseInt(trimmed) };
33
+ }
34
+ return { type: 'file', filePath: trimmed };
35
+ }
36
+
37
+ // ─── Markdown Parsing ────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Extract a markdown section between a heading and the next heading of equal or higher level.
41
+ * Returns the section content (without the heading itself), or null if not found.
42
+ */
43
+ function extractMarkdownSection(content, sectionName, headingLevel) {
44
+ const prefix = '#'.repeat(headingLevel);
45
+ const escapedName = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+ const headingPattern = new RegExp(`^${prefix}\\s+${escapedName}\\s*$`, 'm');
47
+ const headingMatch = headingPattern.exec(content);
48
+ if (!headingMatch) return null;
49
+
50
+ const startIdx = headingMatch.index + headingMatch[0].length;
51
+ const rest = content.substring(startIdx);
52
+
53
+ const nextHeadingPattern = new RegExp(`^#{1,${headingLevel}}\\s`, 'm');
54
+ const nextMatch = nextHeadingPattern.exec(rest);
55
+
56
+ const sectionContent = nextMatch ? rest.substring(0, nextMatch.index) : rest;
57
+ return sectionContent.trim() || null;
58
+ }
59
+
60
+ // ─── Story Metadata ──────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Parse the story markdown header to extract metadata and parent context.
64
+ *
65
+ * Expected format:
66
+ * # S3: Display OAuth Provider Buttons
67
+ * **Feature**: F3 OAuth2 Login Flow | **Epic**: #45 User Authentication
68
+ * **Status**: Refined | **Size**: 3 | **Sprint**: Sprint 2 | **Link**: [#95](url)
69
+ */
70
+ function extractStoryMetadata(content) {
71
+ const result = {
72
+ id: null, title: null, status: null, size: null, sprint: null, link: null,
73
+ feature: { id: null, title: null },
74
+ epic: { id: null, title: null },
75
+ };
76
+ if (!content) return result;
77
+
78
+ // Header: # ID: Title
79
+ const headerMatch = content.match(/^#\s+([^:\n]+?):\s+(.+)$/m);
80
+ if (headerMatch) {
81
+ result.id = headerMatch[1].trim();
82
+ result.title = headerMatch[2].trim();
83
+ }
84
+
85
+ // Feature/Epic line
86
+ const featureEpicMatch = content.match(/\*\*Feature\*\*:\s*(.+?)\s*\|\s*\*\*Epic\*\*:\s*(.+)$/m);
87
+ if (featureEpicMatch) {
88
+ const featureStr = featureEpicMatch[1].trim();
89
+ const epicStr = featureEpicMatch[2].trim();
90
+ const featureParts = featureStr.match(/^(\S+)\s+(.+)$/);
91
+ if (featureParts) {
92
+ result.feature.id = featureParts[1];
93
+ result.feature.title = featureParts[2];
94
+ } else {
95
+ result.feature.title = featureStr;
96
+ }
97
+ const epicParts = epicStr.match(/^(\S+)\s+(.+)$/);
98
+ if (epicParts) {
99
+ result.epic.id = epicParts[1];
100
+ result.epic.title = epicParts[2];
101
+ } else {
102
+ result.epic.title = epicStr;
103
+ }
104
+ }
105
+
106
+ const statusMatch = content.match(/\*\*Status\*\*:\s*([^|*]+)/);
107
+ if (statusMatch) result.status = statusMatch[1].trim();
108
+
109
+ const sizeMatch = content.match(/\*\*Size\*\*:\s*([^|*]+)/);
110
+ if (sizeMatch) result.size = sizeMatch[1].trim();
111
+
112
+ const sprintMatch = content.match(/\*\*Sprint\*\*:\s*([^|*]+)/);
113
+ if (sprintMatch) result.sprint = sprintMatch[1].trim();
114
+
115
+ const linkMatch = content.match(/\*\*Link\*\*:\s*([^|*\n]+)/);
116
+ if (linkMatch) result.link = linkMatch[1].trim();
117
+
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Extract GitHub issue number from a Link field value.
123
+ * Handles formats: "[#187](url)", "#187", "187"
124
+ */
125
+ function extractIssueNumber(linkStr) {
126
+ if (!linkStr) return null;
127
+ const match = linkStr.match(/#(\d+)/);
128
+ return match ? parseInt(match[1], 10) : null;
129
+ }
130
+
131
+ /**
132
+ * Extract GitHub issue number from a file's **Link** header field.
133
+ */
134
+ function extractIssueNumberFromFile(cwd, filePath) {
135
+ if (!filePath) return null;
136
+ const resolved = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
137
+ const content = safeReadFile(resolved);
138
+ if (!content) return null;
139
+ const linkMatch = content.match(/\*\*Link\*\*:\s*([^|*\n]+)/);
140
+ if (!linkMatch) return null;
141
+ return extractIssueNumber(linkMatch[1].trim());
142
+ }
143
+
144
+ // ─── Story Requirements ──────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Extract story requirements: user story statement, description, and AC scenario count.
148
+ */
149
+ function extractStoryRequirements(content) {
150
+ const result = { user_story: null, description: null, acceptance_criteria_count: 0 };
151
+ if (!content) return result;
152
+
153
+ const userStorySection = extractMarkdownSection(content, 'User Story', 2);
154
+ if (userStorySection) {
155
+ result.user_story = userStorySection.replace(/^>\s?/gm, '').trim();
156
+ }
157
+
158
+ const descSection = extractMarkdownSection(content, 'Description', 2);
159
+ if (descSection) {
160
+ result.description = descSection.trim();
161
+ }
162
+
163
+ const scenarioMatches = content.match(/^###\s+Scenario:/gm);
164
+ result.acceptance_criteria_count = scenarioMatches ? scenarioMatches.length : 0;
165
+
166
+ return result;
167
+ }
168
+
169
+ /**
170
+ * Parse the "## Relevant Wiki" section to extract structured wiki file references.
171
+ */
172
+ function extractWikiReferences(content) {
173
+ const result = { system_wide: [], subsystem_docs: [], total_count: 0 };
174
+ if (!content) return result;
175
+
176
+ const wikiSection = extractMarkdownSection(content, 'Relevant Wiki', 2);
177
+ if (!wikiSection) return result;
178
+
179
+ const linePattern = /^-\s+`([^`]+)`\s*[—–-]\s*(.+)$/gm;
180
+ let match;
181
+ while ((match = linePattern.exec(wikiSection)) !== null) {
182
+ const filePath = match[1].trim();
183
+ const reason = match[2].trim();
184
+
185
+ if (filePath.includes('/system-wide/')) {
186
+ result.system_wide.push(filePath);
187
+ } else {
188
+ let category = 'other';
189
+ if (filePath.includes('/systems/')) category = 'systems';
190
+ else if (filePath.includes('/patterns/')) category = 'patterns';
191
+ else if (filePath.includes('/cross-cutting/')) category = 'cross-cutting';
192
+ else if (filePath.includes('/guides/')) category = 'guides';
193
+ else if (filePath.includes('/decisions/')) category = 'decisions';
194
+ else if (filePath.includes('/architecture')) category = 'architecture';
195
+
196
+ result.subsystem_docs.push({ path: filePath, category, reason });
197
+ }
198
+ }
199
+
200
+ result.total_count = result.system_wide.length + result.subsystem_docs.length;
201
+ return result;
202
+ }
203
+
204
+ // ─── Path Computation ────────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Compute all story-related paths and slugs from parent context.
208
+ */
209
+ function computeStoryPaths(epicId, epicTitle, featureId, featureTitle, storyId, storyTitle) {
210
+ const epicSlug = generateSlug(`${epicId}-${epicTitle}`) || 'unknown-epic';
211
+ const featureSlug = generateSlug(`${featureId}-${featureTitle}`) || 'unknown-feature';
212
+ const storySlug = generateSlug(`${storyId}-${storyTitle}`) || 'unknown-story';
213
+
214
+ const storyDir = `.ace/artifacts/product/${epicSlug}/${featureSlug}/${storySlug}`;
215
+ const featureDir = `.ace/artifacts/product/${epicSlug}/${featureSlug}`;
216
+
217
+ return {
218
+ epic_slug: epicSlug,
219
+ feature_slug: featureSlug,
220
+ story_slug: storySlug,
221
+ story_dir: storyDir,
222
+ story_file: `${storyDir}/${storySlug}.md`,
223
+ external_analysis_file: `${storyDir}/external-analysis.md`,
224
+ integration_analysis_file: `${storyDir}/integration-analysis.md`,
225
+ feature_dir: featureDir,
226
+ feature_file: `${featureDir}/${featureSlug}.md`,
227
+ };
228
+ }
229
+
230
+ // ─── Story State Management ──────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Update story status across story file, feature file, and product backlog.
234
+ * Handles: story header update, feature index table update, backlog table update,
235
+ * and auto-promotes feature to Done when all stories are Done.
236
+ *
237
+ * Called via: node script.js update-state story=<path> status=<Refined|InProgress|Done|DevReady>
238
+ */
239
+ function updateState(cwd, raw, extraArgs) {
240
+ const params = parseKeyValueArgs(extraArgs);
241
+ const storyParam = params.story;
242
+ const newStatus = params.status;
243
+
244
+ if (!storyParam) {
245
+ error('update-state requires: story=<path>');
246
+ }
247
+ if (!newStatus || !['Done', 'DevReady', 'Refined', 'InProgress', 'In Progress'].includes(newStatus)) {
248
+ error('update-state requires: status=Done|DevReady|Refined|InProgress');
249
+ }
250
+
251
+ const displayStatus = newStatus === 'InProgress' ? 'In Progress' : newStatus;
252
+
253
+ const result = {
254
+ story_updated: false,
255
+ feature_updated: false,
256
+ backlog_updated: false,
257
+ feature_status_changed: false,
258
+ new_status: displayStatus,
259
+ errors: [],
260
+ };
261
+
262
+ // Resolve story file path
263
+ const classified = classifyStoryParam(storyParam);
264
+ if (classified.type !== 'file' || !classified.filePath) {
265
+ result.errors.push('update-state currently only supports file paths');
266
+ output(result, raw);
267
+ return;
268
+ }
269
+
270
+ const storyFilePath = path.isAbsolute(classified.filePath)
271
+ ? classified.filePath
272
+ : path.join(cwd, classified.filePath);
273
+
274
+ // 1. Update story file header
275
+ const storyContent = safeReadFile(storyFilePath);
276
+ if (!storyContent) {
277
+ result.errors.push(`Could not read story file: ${classified.filePath}`);
278
+ output(result, raw);
279
+ return;
280
+ }
281
+
282
+ const updatedStory = storyContent.replace(
283
+ /(\*\*Status\*\*:\s*)([^|*\n]+)/,
284
+ `$1${displayStatus}`
285
+ );
286
+ if (updatedStory !== storyContent) {
287
+ try {
288
+ fs.writeFileSync(storyFilePath, updatedStory, 'utf-8');
289
+ result.story_updated = true;
290
+ } catch (e) {
291
+ result.errors.push(`Failed to write story file: ${e.message}`);
292
+ }
293
+ }
294
+
295
+ const metadata = extractStoryMetadata(storyContent);
296
+ const storyId = metadata.id;
297
+
298
+ // 2. Update feature file story index
299
+ const storyDir = path.dirname(storyFilePath);
300
+ const featureDir = path.dirname(storyDir);
301
+ const featureSlug = path.basename(featureDir);
302
+ const featureFilePath = path.join(featureDir, `${featureSlug}.md`);
303
+
304
+ const featureContent = safeReadFile(featureFilePath);
305
+ if (featureContent && storyId) {
306
+ const storyIdEscaped = storyId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
+ const tableRowPattern = new RegExp(
308
+ `(\\|\\s*${storyIdEscaped}\\s*\\|[^|]*\\|[^|]*\\|\\s*)([^|]*)(\\s*\\|)`,
309
+ 'm'
310
+ );
311
+ const updatedFeature = featureContent.replace(tableRowPattern, `$1${displayStatus}$3`);
312
+
313
+ if (updatedFeature !== featureContent) {
314
+ try {
315
+ fs.writeFileSync(featureFilePath, updatedFeature, 'utf-8');
316
+ result.feature_updated = true;
317
+ } catch (e) {
318
+ result.errors.push(`Failed to write feature file: ${e.message}`);
319
+ }
320
+ }
321
+
322
+ // Check if all stories in the feature are Done
323
+ if (displayStatus === 'Done') {
324
+ const updatedFeatureContent = safeReadFile(featureFilePath) || updatedFeature;
325
+ const statusPattern = /\|\s*(?:S\d+|#\d+)\s*\|[^|]*\|[^|]*\|\s*([^|]*)\s*\|/gm;
326
+ let allDone = true;
327
+ let match;
328
+ let storyCount = 0;
329
+ while ((match = statusPattern.exec(updatedFeatureContent)) !== null) {
330
+ storyCount++;
331
+ const status = match[1].trim();
332
+ if (status !== 'Done') {
333
+ allDone = false;
334
+ }
335
+ }
336
+
337
+ if (allDone && storyCount > 0) {
338
+ const featureWithDoneStatus = updatedFeatureContent.replace(
339
+ /(\*\*Status\*\*:\s*)([^|*\n]+)/,
340
+ '$1Done'
341
+ );
342
+ if (featureWithDoneStatus !== updatedFeatureContent) {
343
+ try {
344
+ fs.writeFileSync(featureFilePath, featureWithDoneStatus, 'utf-8');
345
+ result.feature_status_changed = true;
346
+ } catch (e) {
347
+ result.errors.push(`Failed to update feature status: ${e.message}`);
348
+ }
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ // 3. Update product backlog
355
+ const backlogPath = path.join(cwd, '.ace', 'artifacts', 'product', 'product-backlog.md');
356
+ const backlogContent = safeReadFile(backlogPath);
357
+ if (backlogContent && storyId) {
358
+ let updatedBacklog = backlogContent;
359
+
360
+ const storyIdEscaped = storyId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
361
+ const backlogStoryPattern = new RegExp(
362
+ `(\\|\\s*${storyIdEscaped}\\s*\\|[^|]*\\|[^|]*\\|\\s*)([^|]*)(\\s*\\|)`,
363
+ 'm'
364
+ );
365
+ updatedBacklog = updatedBacklog.replace(backlogStoryPattern, `$1${displayStatus}$3`);
366
+
367
+ if (result.feature_status_changed && metadata.feature.id) {
368
+ const featureIdEscaped = metadata.feature.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
369
+ const backlogFeaturePattern = new RegExp(
370
+ `(\\|\\s*${featureIdEscaped}\\s*\\|[^|]*\\|[^|]*\\|\\s*)([^|]*)(\\s*\\|)`,
371
+ 'm'
372
+ );
373
+ updatedBacklog = updatedBacklog.replace(backlogFeaturePattern, `$1Done$3`);
374
+ }
375
+
376
+ if (updatedBacklog !== backlogContent) {
377
+ try {
378
+ fs.writeFileSync(backlogPath, updatedBacklog, 'utf-8');
379
+ result.backlog_updated = true;
380
+ } catch (e) {
381
+ result.errors.push(`Failed to write product backlog: ${e.message}`);
382
+ }
383
+ }
384
+ }
385
+
386
+ if (result.errors.length === 0) delete result.errors;
387
+ output(result, raw);
388
+ }
389
+
390
+ module.exports = {
391
+ classifyStoryParam,
392
+ extractMarkdownSection,
393
+ extractStoryMetadata,
394
+ extractIssueNumber,
395
+ extractIssueNumberFromFile,
396
+ extractStoryRequirements,
397
+ extractWikiReferences,
398
+ computeStoryPaths,
399
+ updateState,
400
+ };
@@ -0,0 +1,250 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const {
5
+ classifyStoryParam, extractMarkdownSection, extractStoryMetadata,
6
+ extractIssueNumber, extractStoryRequirements, extractWikiReferences,
7
+ computeStoryPaths,
8
+ } = require('./ace-story');
9
+
10
+ const SAMPLE_STORY = `# S3: Display OAuth Provider Buttons
11
+
12
+ **Feature**: F3 OAuth2 Login Flow | **Epic**: #45 User Authentication
13
+ **Status**: Refined | **Size**: 3 | **Sprint**: Sprint 2 | **Link**: [#95](https://github.com/owner/repo/issues/95)
14
+
15
+ ## User Story
16
+
17
+ > As a returning customer,
18
+ > I want to click a Google or GitHub login button,
19
+ > so that I can authenticate without remembering a site-specific password.
20
+
21
+ ## Description
22
+
23
+ This story adds OAuth provider buttons to the login page. It builds on the
24
+ auth service foundation (S1) and enables the token exchange flow (S4).
25
+
26
+ ## Acceptance Criteria
27
+
28
+ ### Scenario: Successful Google login
29
+
30
+ **Given** the user is on the login page and has a valid Google account
31
+ **When** they click the "Sign in with Google" button and complete Google's OAuth flow
32
+ **Then** they are redirected to the dashboard and see their Google profile name
33
+
34
+ ### Scenario: Provider unavailable
35
+
36
+ **Given** the user is on the login page and the Google OAuth service is unreachable
37
+ **When** they click the "Sign in with Google" button
38
+ **Then** they see an error message "Login service temporarily unavailable. Please try again."
39
+
40
+ ### Scenario: GitHub login button displayed
41
+
42
+ **Given** the user navigates to the login page
43
+ **When** the page loads
44
+ **Then** they see a "Sign in with GitHub" button alongside the Google button
45
+
46
+ ## Out of Scope
47
+
48
+ - Token refresh logic (handled by S4)
49
+ - Account linking (future feature)
50
+
51
+ ## Definition of Done
52
+
53
+ - [ ] All acceptance criteria scenarios pass
54
+ - [ ] Code reviewed and approved
55
+
56
+ ## Relevant Wiki
57
+
58
+ ### System-Wide
59
+
60
+ - \`.docs/wiki/system-wide/system-structure.md\` — Mandatory system-wide context
61
+ - \`.docs/wiki/system-wide/coding-standards.md\` — Mandatory system-wide context
62
+
63
+ ### Systems
64
+ - \`.docs/wiki/subsystems/auth/systems/oauth-provider.md\` — Implements the provider abstraction
65
+
66
+ ### Patterns
67
+ - \`.docs/wiki/subsystems/auth/patterns/strategy-pattern.md\` — Each OAuth provider is a strategy
68
+ `;
69
+
70
+ // ─── classifyStoryParam ──────────────────────────────────────────────────────
71
+
72
+ describe('classifyStoryParam', () => {
73
+ it('classifies file path', () => {
74
+ const result = classifyStoryParam('.ace/artifacts/product/e1/f1/s1/s1.md');
75
+ assert.strictEqual(result.type, 'file');
76
+ assert.ok(result.filePath.includes('s1.md'));
77
+ });
78
+
79
+ it('classifies GitHub URL', () => {
80
+ const result = classifyStoryParam('https://github.com/owner/repo/issues/123');
81
+ assert.strictEqual(result.type, 'github-url');
82
+ assert.strictEqual(result.repo, 'owner/repo');
83
+ assert.strictEqual(result.issueNumber, 123);
84
+ });
85
+
86
+ it('classifies issue number', () => {
87
+ const result = classifyStoryParam('42');
88
+ assert.strictEqual(result.type, 'issue-number');
89
+ assert.strictEqual(result.issueNumber, 42);
90
+ });
91
+
92
+ it('returns null type for empty input', () => {
93
+ assert.strictEqual(classifyStoryParam(null).type, null);
94
+ assert.strictEqual(classifyStoryParam('').type, null);
95
+ assert.strictEqual(classifyStoryParam(undefined).type, null);
96
+ });
97
+
98
+ it('returns invalid for unrecognized GitHub URL', () => {
99
+ const result = classifyStoryParam('https://github.com/owner/repo/pulls/5');
100
+ assert.strictEqual(result.type, 'invalid');
101
+ });
102
+ });
103
+
104
+ // ─── extractMarkdownSection ──────────────────────────────────────────────────
105
+
106
+ describe('extractMarkdownSection', () => {
107
+ it('extracts section content', () => {
108
+ const result = extractMarkdownSection(SAMPLE_STORY, 'Description', 2);
109
+ assert.ok(result.includes('OAuth provider buttons'));
110
+ });
111
+
112
+ it('returns null for non-existent section', () => {
113
+ assert.strictEqual(extractMarkdownSection(SAMPLE_STORY, 'Nonexistent', 2), null);
114
+ });
115
+
116
+ it('stops at next heading of same level', () => {
117
+ const result = extractMarkdownSection(SAMPLE_STORY, 'Out of Scope', 2);
118
+ assert.ok(result.includes('Token refresh'));
119
+ assert.ok(!result.includes('Definition of Done'));
120
+ });
121
+ });
122
+
123
+ // ─── extractStoryMetadata ────────────────────────────────────────────────────
124
+
125
+ describe('extractStoryMetadata', () => {
126
+ it('extracts full metadata from sample story', () => {
127
+ const meta = extractStoryMetadata(SAMPLE_STORY);
128
+ assert.strictEqual(meta.id, 'S3');
129
+ assert.strictEqual(meta.title, 'Display OAuth Provider Buttons');
130
+ assert.strictEqual(meta.status, 'Refined');
131
+ assert.strictEqual(meta.size, '3');
132
+ assert.strictEqual(meta.sprint, 'Sprint 2');
133
+ assert.strictEqual(meta.feature.id, 'F3');
134
+ assert.strictEqual(meta.feature.title, 'OAuth2 Login Flow');
135
+ assert.strictEqual(meta.epic.id, '#45');
136
+ assert.strictEqual(meta.epic.title, 'User Authentication');
137
+ });
138
+
139
+ it('returns nulls for empty content', () => {
140
+ const meta = extractStoryMetadata(null);
141
+ assert.strictEqual(meta.id, null);
142
+ assert.strictEqual(meta.title, null);
143
+ assert.strictEqual(meta.feature.id, null);
144
+ });
145
+
146
+ it('extracts link field', () => {
147
+ const meta = extractStoryMetadata(SAMPLE_STORY);
148
+ assert.ok(meta.link.includes('#95'));
149
+ });
150
+ });
151
+
152
+ // ─── extractIssueNumber ──────────────────────────────────────────────────────
153
+
154
+ describe('extractIssueNumber', () => {
155
+ it('extracts from markdown link format', () => {
156
+ assert.strictEqual(extractIssueNumber('[#187](https://github.com/owner/repo/issues/187)'), 187);
157
+ });
158
+
159
+ it('extracts from hash format', () => {
160
+ assert.strictEqual(extractIssueNumber('#95'), 95);
161
+ });
162
+
163
+ it('returns null for null input', () => {
164
+ assert.strictEqual(extractIssueNumber(null), null);
165
+ });
166
+
167
+ it('returns null for no match', () => {
168
+ assert.strictEqual(extractIssueNumber('no number here'), null);
169
+ });
170
+ });
171
+
172
+ // ─── extractStoryRequirements ────────────────────────────────────────────────
173
+
174
+ describe('extractStoryRequirements', () => {
175
+ it('extracts user story, description, and AC count', () => {
176
+ const req = extractStoryRequirements(SAMPLE_STORY);
177
+ assert.ok(req.user_story.includes('returning customer'));
178
+ assert.ok(req.description.includes('OAuth provider buttons'));
179
+ assert.strictEqual(req.acceptance_criteria_count, 3);
180
+ });
181
+
182
+ it('strips blockquote prefix from user story', () => {
183
+ const req = extractStoryRequirements(SAMPLE_STORY);
184
+ assert.ok(!req.user_story.startsWith('>'));
185
+ });
186
+
187
+ it('returns zeros/nulls for empty content', () => {
188
+ const req = extractStoryRequirements(null);
189
+ assert.strictEqual(req.user_story, null);
190
+ assert.strictEqual(req.description, null);
191
+ assert.strictEqual(req.acceptance_criteria_count, 0);
192
+ });
193
+ });
194
+
195
+ // ─── extractWikiReferences ───────────────────────────────────────────────────
196
+
197
+ describe('extractWikiReferences', () => {
198
+ it('extracts system-wide references', () => {
199
+ const refs = extractWikiReferences(SAMPLE_STORY);
200
+ assert.strictEqual(refs.system_wide.length, 2);
201
+ assert.ok(refs.system_wide.includes('.docs/wiki/system-wide/system-structure.md'));
202
+ });
203
+
204
+ it('extracts subsystem docs with categories', () => {
205
+ const refs = extractWikiReferences(SAMPLE_STORY);
206
+ assert.strictEqual(refs.subsystem_docs.length, 2);
207
+
208
+ const oauthDoc = refs.subsystem_docs.find(d => d.path.includes('oauth-provider'));
209
+ assert.ok(oauthDoc);
210
+ assert.strictEqual(oauthDoc.category, 'systems');
211
+
212
+ const strategyDoc = refs.subsystem_docs.find(d => d.path.includes('strategy-pattern'));
213
+ assert.ok(strategyDoc);
214
+ assert.strictEqual(strategyDoc.category, 'patterns');
215
+ });
216
+
217
+ it('computes total count', () => {
218
+ const refs = extractWikiReferences(SAMPLE_STORY);
219
+ assert.strictEqual(refs.total_count, 4);
220
+ });
221
+
222
+ it('returns empty for content without wiki section', () => {
223
+ const refs = extractWikiReferences('# No wiki here');
224
+ assert.strictEqual(refs.total_count, 0);
225
+ assert.deepStrictEqual(refs.system_wide, []);
226
+ });
227
+ });
228
+
229
+ // ─── computeStoryPaths ───────────────────────────────────────────────────────
230
+
231
+ describe('computeStoryPaths', () => {
232
+ it('generates correct slugs and paths', () => {
233
+ const paths = computeStoryPaths('E1', 'Platform', 'F3', 'OAuth Login', 'S1', 'Add Button');
234
+ assert.strictEqual(paths.epic_slug, 'e1-platform');
235
+ assert.strictEqual(paths.feature_slug, 'f3-oauth-login');
236
+ assert.strictEqual(paths.story_slug, 's1-add-button');
237
+ assert.strictEqual(paths.story_dir, '.ace/artifacts/product/e1-platform/f3-oauth-login/s1-add-button');
238
+ assert.strictEqual(paths.story_file, '.ace/artifacts/product/e1-platform/f3-oauth-login/s1-add-button/s1-add-button.md');
239
+ assert.ok(paths.external_analysis_file.endsWith('external-analysis.md'));
240
+ assert.ok(paths.integration_analysis_file.endsWith('integration-analysis.md'));
241
+ assert.ok(paths.feature_file.endsWith('f3-oauth-login.md'));
242
+ });
243
+
244
+ it('handles missing titles with fallback slugs', () => {
245
+ const paths = computeStoryPaths('', '', '', '', '', '');
246
+ assert.strictEqual(paths.epic_slug, 'unknown-epic');
247
+ assert.strictEqual(paths.feature_slug, 'unknown-feature');
248
+ assert.strictEqual(paths.story_slug, 'unknown-story');
249
+ });
250
+ });