agile-context-engineering 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/CHANGELOG.md +7 -1
  4. package/LICENSE +51 -51
  5. package/README.md +330 -318
  6. package/agents/ace-code-discovery-analyst.md +245 -245
  7. package/agents/ace-code-integration-analyst.md +248 -248
  8. package/agents/ace-code-reviewer.md +375 -375
  9. package/agents/ace-product-owner.md +365 -361
  10. package/agents/ace-project-researcher.md +606 -606
  11. package/agents/ace-research-synthesizer.md +228 -228
  12. package/agents/ace-technical-application-architect.md +315 -315
  13. package/agents/ace-wiki-mapper.md +449 -445
  14. package/bin/install.js +605 -195
  15. package/hooks/ace-check-update.js +71 -62
  16. package/hooks/ace-statusline.js +107 -89
  17. package/hooks/hooks.json +14 -0
  18. package/package.json +7 -5
  19. package/shared/lib/ace-core.js +361 -0
  20. package/shared/lib/ace-core.test.js +308 -0
  21. package/shared/lib/ace-github.js +753 -0
  22. package/shared/lib/ace-story.js +400 -0
  23. package/shared/lib/ace-story.test.js +250 -0
  24. package/{agile-context-engineering → shared}/utils/questioning.xml +110 -110
  25. package/{agile-context-engineering → shared}/utils/ui-formatting.md +299 -299
  26. package/{commands/ace/execute-story.md → skills/execute-story/SKILL.md} +116 -138
  27. package/skills/execute-story/script.js +291 -0
  28. package/skills/execute-story/script.test.js +261 -0
  29. package/{agile-context-engineering/templates/product/story.xml → skills/execute-story/story-template.xml} +451 -451
  30. package/skills/execute-story/walkthrough-template.xml +255 -0
  31. package/{agile-context-engineering/workflows/execute-story.xml → skills/execute-story/workflow.xml} +1221 -1219
  32. package/skills/help/SKILL.md +71 -0
  33. package/skills/help/script.js +315 -0
  34. package/skills/help/script.test.js +183 -0
  35. package/{agile-context-engineering/workflows/help.xml → skills/help/workflow.xml} +544 -533
  36. package/{commands/ace/init-coding-standards.md → skills/init-coding-standards/SKILL.md} +91 -83
  37. package/{agile-context-engineering/templates/wiki/coding-standards.xml → skills/init-coding-standards/coding-standards-template.xml} +531 -531
  38. package/skills/init-coding-standards/script.js +50 -0
  39. package/skills/init-coding-standards/script.test.js +70 -0
  40. package/{agile-context-engineering/workflows/init-coding-standards.xml → skills/init-coding-standards/workflow.xml} +381 -386
  41. package/skills/map-cross-cutting/SKILL.md +126 -0
  42. package/{agile-context-engineering/templates/wiki → skills/map-cross-cutting}/system-cross-cutting.xml +197 -197
  43. package/skills/map-cross-cutting/workflow.xml +330 -0
  44. package/skills/map-guide/SKILL.md +126 -0
  45. package/{agile-context-engineering/templates/wiki → skills/map-guide}/guide.xml +137 -137
  46. package/skills/map-guide/workflow.xml +320 -0
  47. package/skills/map-pattern/SKILL.md +125 -0
  48. package/{agile-context-engineering/templates/wiki → skills/map-pattern}/pattern.xml +159 -159
  49. package/skills/map-pattern/workflow.xml +331 -0
  50. package/{commands/ace/map-story.md → skills/map-story/SKILL.md} +180 -165
  51. package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/decizions.xml +115 -115
  52. package/skills/map-story/templates/guide.xml +137 -0
  53. package/skills/map-story/templates/pattern.xml +159 -0
  54. package/skills/map-story/templates/system-cross-cutting.xml +197 -0
  55. package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/system.xml +381 -381
  56. package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/tech-debt-index.xml +125 -125
  57. package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/walkthrough.xml +255 -255
  58. package/{agile-context-engineering/workflows/map-story.xml → skills/map-story/workflow.xml} +1046 -1046
  59. package/{commands/ace/map-subsystem.md → skills/map-subsystem/SKILL.md} +155 -140
  60. package/skills/map-subsystem/script.js +51 -0
  61. package/skills/map-subsystem/script.test.js +68 -0
  62. package/skills/map-subsystem/templates/decizions.xml +115 -0
  63. package/skills/map-subsystem/templates/guide.xml +137 -0
  64. package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/module-discovery.xml +174 -174
  65. package/skills/map-subsystem/templates/pattern.xml +159 -0
  66. package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-architecture.xml +343 -343
  67. package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-structure.xml +234 -234
  68. package/skills/map-subsystem/templates/system-cross-cutting.xml +197 -0
  69. package/skills/map-subsystem/templates/system.xml +381 -0
  70. package/skills/map-subsystem/templates/walkthrough.xml +255 -0
  71. package/{agile-context-engineering/workflows/map-subsystem.xml → skills/map-subsystem/workflow.xml} +1173 -1178
  72. package/skills/map-sys-doc/SKILL.md +125 -0
  73. package/skills/map-sys-doc/system.xml +381 -0
  74. package/skills/map-sys-doc/workflow.xml +336 -0
  75. package/{commands/ace/map-system.md → skills/map-system/SKILL.md} +103 -92
  76. package/skills/map-system/script.js +75 -0
  77. package/skills/map-system/script.test.js +73 -0
  78. package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-architecture.xml +254 -254
  79. package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-structure.xml +177 -177
  80. package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/testing-framework.xml +283 -283
  81. package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/wiki-readme.xml +296 -296
  82. package/{agile-context-engineering/workflows/map-system.xml → skills/map-system/workflow.xml} +667 -672
  83. package/{commands/ace/map-walkthrough.md → skills/map-walkthrough/SKILL.md} +140 -127
  84. package/skills/map-walkthrough/walkthrough.xml +255 -0
  85. package/{agile-context-engineering/workflows/map-walkthrough.xml → skills/map-walkthrough/workflow.xml} +457 -457
  86. package/{commands/ace/plan-backlog.md → skills/plan-backlog/SKILL.md} +93 -83
  87. package/{agile-context-engineering/templates/product/product-backlog.xml → skills/plan-backlog/product-backlog-template.xml} +231 -231
  88. package/skills/plan-backlog/script.js +121 -0
  89. package/skills/plan-backlog/script.test.js +83 -0
  90. package/{agile-context-engineering/workflows/plan-backlog.xml → skills/plan-backlog/workflow.xml} +1348 -1356
  91. package/{commands/ace/plan-feature.md → skills/plan-feature/SKILL.md} +99 -89
  92. package/{agile-context-engineering/templates/product/feature.xml → skills/plan-feature/feature-template.xml} +361 -361
  93. package/skills/plan-feature/script.js +131 -0
  94. package/skills/plan-feature/script.test.js +80 -0
  95. package/{agile-context-engineering/workflows/plan-feature.xml → skills/plan-feature/workflow.xml} +1487 -1495
  96. package/{commands/ace/plan-product-vision.md → skills/plan-product-vision/SKILL.md} +91 -81
  97. package/{agile-context-engineering/templates/product/product-vision.xml → skills/plan-product-vision/product-vision-template.xml} +227 -227
  98. package/skills/plan-product-vision/script.js +51 -0
  99. package/skills/plan-product-vision/script.test.js +69 -0
  100. package/{agile-context-engineering/workflows/plan-product-vision.xml → skills/plan-product-vision/workflow.xml} +337 -342
  101. package/{commands/ace/plan-story.md → skills/plan-story/SKILL.md} +139 -159
  102. package/skills/plan-story/script.js +295 -0
  103. package/skills/plan-story/script.test.js +240 -0
  104. package/skills/plan-story/story-template.xml +458 -0
  105. package/{agile-context-engineering/workflows/plan-story.xml → skills/plan-story/workflow.xml} +1301 -944
  106. package/{commands/ace/research-external-solution.md → skills/research-external-solution/SKILL.md} +120 -138
  107. package/{agile-context-engineering/templates/product/external-solution.xml → skills/research-external-solution/external-solution-template.xml} +832 -832
  108. package/skills/research-external-solution/script.js +229 -0
  109. package/skills/research-external-solution/script.test.js +134 -0
  110. package/{agile-context-engineering/workflows/research-external-solution.xml → skills/research-external-solution/workflow.xml} +657 -659
  111. package/{commands/ace/research-integration-solution.md → skills/research-integration-solution/SKILL.md} +121 -135
  112. package/{agile-context-engineering/templates/product/story-integration-solution.xml → skills/research-integration-solution/integration-solution-template.xml} +1015 -1015
  113. package/skills/research-integration-solution/script.js +223 -0
  114. package/skills/research-integration-solution/script.test.js +134 -0
  115. package/{agile-context-engineering/workflows/research-integration-solution.xml → skills/research-integration-solution/workflow.xml} +711 -713
  116. package/{commands/ace/research-story-wiki.md → skills/research-story-wiki/SKILL.md} +101 -116
  117. package/skills/research-story-wiki/script.js +223 -0
  118. package/skills/research-story-wiki/script.test.js +138 -0
  119. package/{agile-context-engineering/templates/product/story-wiki.xml → skills/research-story-wiki/story-wiki-template.xml} +194 -194
  120. package/{agile-context-engineering/workflows/research-story-wiki.xml → skills/research-story-wiki/workflow.xml} +473 -475
  121. package/{commands/ace/research-technical-solution.md → skills/research-technical-solution/SKILL.md} +131 -147
  122. package/skills/research-technical-solution/script.js +223 -0
  123. package/skills/research-technical-solution/script.test.js +134 -0
  124. package/{agile-context-engineering/templates/product/story-technical-solution.xml → skills/research-technical-solution/technical-solution-template.xml} +1025 -1025
  125. package/{agile-context-engineering/workflows/research-technical-solution.xml → skills/research-technical-solution/workflow.xml} +761 -763
  126. package/{commands/ace/review-story.md → skills/review-story/SKILL.md} +99 -109
  127. package/skills/review-story/script.js +249 -0
  128. package/skills/review-story/script.test.js +169 -0
  129. package/skills/review-story/story-template.xml +451 -0
  130. package/{agile-context-engineering/workflows/review-story.xml → skills/review-story/workflow.xml} +279 -281
  131. package/{commands/ace/update.md → skills/update/SKILL.md} +65 -56
  132. package/{agile-context-engineering/workflows/update.xml → skills/update/workflow.xml} +33 -18
  133. package/agile-context-engineering/src/ace-tools.js +0 -2881
  134. package/agile-context-engineering/src/ace-tools.test.js +0 -1089
  135. package/agile-context-engineering/templates/_command.md +0 -54
  136. package/agile-context-engineering/templates/_workflow.xml +0 -17
  137. package/agile-context-engineering/templates/config.json +0 -0
  138. package/agile-context-engineering/templates/product/integration-solution.xml +0 -0
  139. package/commands/ace/help.md +0 -93
@@ -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
+ });