agileflow 2.43.0 → 2.45.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.
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Skill Registry Scanner
5
+ *
6
+ * Scans skills/ directory and extracts metadata from SKILL.md frontmatter.
7
+ * Returns structured skill registry for use in generators.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ /**
14
+ * Extract YAML frontmatter from markdown file
15
+ * @param {string} filePath - Path to markdown file
16
+ * @returns {object} Frontmatter object
17
+ */
18
+ function extractFrontmatter(filePath) {
19
+ const content = fs.readFileSync(filePath, 'utf-8');
20
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
21
+
22
+ if (!frontmatterMatch) {
23
+ return {};
24
+ }
25
+
26
+ const frontmatter = {};
27
+ const lines = frontmatterMatch[1].split('\n');
28
+
29
+ for (const line of lines) {
30
+ const match = line.match(/^(\w+):\s*(.+)$/);
31
+ if (match) {
32
+ const [, key, value] = match;
33
+ // Remove quotes if present
34
+ frontmatter[key] = value.replace(/^["']|["']$/g, '');
35
+ }
36
+ }
37
+
38
+ return frontmatter;
39
+ }
40
+
41
+ /**
42
+ * Categorize skill based on its name/description
43
+ * @param {string} name - Skill name
44
+ * @param {string} description - Skill description
45
+ * @returns {string} Category name
46
+ */
47
+ function categorizeSkill(name, description) {
48
+ const categories = {
49
+ 'Story & Planning': ['story', 'epic', 'sprint', 'acceptance-criteria'],
50
+ 'Code Generation': ['type-definitions', 'validation-schema', 'error-handler'],
51
+ 'Testing': ['test-case'],
52
+ 'Documentation': ['adr', 'api-documentation', 'changelog', 'pr-description'],
53
+ 'Architecture': ['sql-schema', 'diagram'],
54
+ 'Deployment': ['deployment-guide', 'migration-checklist']
55
+ };
56
+
57
+ const lowerName = name.toLowerCase();
58
+ const lowerDesc = description.toLowerCase();
59
+
60
+ for (const [category, keywords] of Object.entries(categories)) {
61
+ if (keywords.some(kw => lowerName.includes(kw) || lowerDesc.includes(kw))) {
62
+ return category;
63
+ }
64
+ }
65
+
66
+ return 'Other';
67
+ }
68
+
69
+ /**
70
+ * Scan skills directory and build registry
71
+ * @param {string} skillsDir - Path to skills directory
72
+ * @returns {Array} Array of skill metadata objects
73
+ */
74
+ function scanSkills(skillsDir) {
75
+ const skills = [];
76
+
77
+ // Each skill is in its own directory with a SKILL.md file
78
+ const skillDirs = fs.readdirSync(skillsDir);
79
+
80
+ for (const skillDir of skillDirs) {
81
+ const skillPath = path.join(skillsDir, skillDir);
82
+
83
+ // Skip if not a directory
84
+ if (!fs.statSync(skillPath).isDirectory()) continue;
85
+
86
+ const skillFile = path.join(skillPath, 'SKILL.md');
87
+
88
+ // Skip if SKILL.md doesn't exist
89
+ if (!fs.existsSync(skillFile)) continue;
90
+
91
+ const frontmatter = extractFrontmatter(skillFile);
92
+ const name = frontmatter.name || skillDir;
93
+ const description = frontmatter.description || '';
94
+
95
+ skills.push({
96
+ name,
97
+ directory: skillDir,
98
+ file: 'SKILL.md',
99
+ path: skillFile,
100
+ description,
101
+ category: categorizeSkill(name, description)
102
+ });
103
+ }
104
+
105
+ // Sort by category, then by name
106
+ skills.sort((a, b) => {
107
+ if (a.category !== b.category) {
108
+ return a.category.localeCompare(b.category);
109
+ }
110
+ return a.name.localeCompare(b.name);
111
+ });
112
+
113
+ return skills;
114
+ }
115
+
116
+ /**
117
+ * Main function
118
+ */
119
+ function main() {
120
+ const rootDir = path.resolve(__dirname, '../..');
121
+ const skillsDir = path.join(rootDir, 'src/core/skills');
122
+
123
+ if (!fs.existsSync(skillsDir)) {
124
+ console.error(`Skills directory not found: ${skillsDir}`);
125
+ process.exit(1);
126
+ }
127
+
128
+ const skills = scanSkills(skillsDir);
129
+
130
+ // If called directly, output JSON
131
+ if (require.main === module) {
132
+ console.log(JSON.stringify(skills, null, 2));
133
+ }
134
+
135
+ return skills;
136
+ }
137
+
138
+ // Export for use in other scripts
139
+ module.exports = { scanSkills, extractFrontmatter, categorizeSkill };
140
+
141
+ // Run if called directly
142
+ if (require.main === module) {
143
+ main();
144
+ }
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * get-env.js - Helper script to output environment information
5
+ *
6
+ * This script can be called from hooks or other automation to get
7
+ * consistent environment information about the AgileFlow project.
8
+ *
9
+ * Usage:
10
+ * node scripts/get-env.js [--json] [--compact]
11
+ *
12
+ * Flags:
13
+ * --json Output as JSON
14
+ * --compact Minimal output for status line
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const { execSync } = require('child_process');
21
+
22
+ function getProjectInfo() {
23
+ const rootDir = path.resolve(__dirname, '..');
24
+
25
+ // Read package.json files
26
+ let cliPackage = {};
27
+ let rootPackage = {};
28
+
29
+ try {
30
+ cliPackage = JSON.parse(
31
+ fs.readFileSync(path.join(rootDir, 'packages/cli/package.json'), 'utf8')
32
+ );
33
+ } catch (err) {
34
+ // Ignore if not found
35
+ }
36
+
37
+ try {
38
+ rootPackage = JSON.parse(
39
+ fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')
40
+ );
41
+ } catch (err) {
42
+ // Ignore if not found
43
+ }
44
+
45
+ // Get git info
46
+ let gitBranch = 'unknown';
47
+ let gitCommit = 'unknown';
48
+ let recentCommits = [];
49
+
50
+ try {
51
+ gitBranch = execSync('git branch --show-current', {
52
+ cwd: rootDir,
53
+ encoding: 'utf8'
54
+ }).trim();
55
+ gitCommit = execSync('git rev-parse --short HEAD', {
56
+ cwd: rootDir,
57
+ encoding: 'utf8'
58
+ }).trim();
59
+
60
+ // Get recent commits (last 5)
61
+ const commitLog = execSync('git log --oneline -5 2>/dev/null', {
62
+ cwd: rootDir,
63
+ encoding: 'utf8'
64
+ }).trim();
65
+ recentCommits = commitLog.split('\n').filter(Boolean);
66
+ } catch (err) {
67
+ // Ignore if git not available
68
+ }
69
+
70
+ // Get AgileFlow status info
71
+ let activeStories = [];
72
+ let wipCount = 0;
73
+ let blockedCount = 0;
74
+ let activeEpics = [];
75
+
76
+ try {
77
+ const statusPath = path.join(rootDir, 'docs/09-agents/status.json');
78
+ if (fs.existsSync(statusPath)) {
79
+ const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
80
+
81
+ // Get active stories
82
+ if (status.stories) {
83
+ Object.entries(status.stories).forEach(([id, story]) => {
84
+ if (story.status === 'in_progress') {
85
+ activeStories.push({ id, title: story.title, owner: story.owner });
86
+ wipCount++;
87
+ }
88
+ if (story.status === 'blocked') {
89
+ blockedCount++;
90
+ }
91
+ });
92
+ }
93
+
94
+ // Get active epics
95
+ if (status.epics) {
96
+ Object.entries(status.epics).forEach(([id, epic]) => {
97
+ if (epic.status !== 'complete') {
98
+ activeEpics.push({ id, title: epic.title });
99
+ }
100
+ });
101
+ }
102
+ }
103
+ } catch (err) {
104
+ // Ignore if status.json not available
105
+ }
106
+
107
+ return {
108
+ project: {
109
+ name: cliPackage.name || rootPackage.name || 'AgileFlow',
110
+ version: cliPackage.version || rootPackage.version || 'unknown',
111
+ description: cliPackage.description || rootPackage.description || '',
112
+ rootDir: rootDir,
113
+ },
114
+ git: {
115
+ branch: gitBranch,
116
+ commit: gitCommit,
117
+ recentCommits: recentCommits,
118
+ },
119
+ agileflow: {
120
+ activeStories: activeStories,
121
+ wipCount: wipCount,
122
+ blockedCount: blockedCount,
123
+ activeEpics: activeEpics,
124
+ },
125
+ system: {
126
+ node: process.version,
127
+ platform: os.platform(),
128
+ arch: os.arch(),
129
+ hostname: os.hostname(),
130
+ user: os.userInfo().username,
131
+ },
132
+ timestamp: new Date().toISOString(),
133
+ };
134
+ }
135
+
136
+ function formatOutput(info, asJson = false, compact = false) {
137
+ if (asJson) {
138
+ return JSON.stringify(info, null, 2);
139
+ }
140
+
141
+ if (compact) {
142
+ // Minimal output for status line
143
+ const story = info.agileflow.activeStories[0];
144
+ const storyStr = story ? `${story.id}: ${story.title.substring(0, 30)}` : 'No active story';
145
+ return `[${info.git.branch}] ${storyStr} | WIP: ${info.agileflow.wipCount}`;
146
+ }
147
+
148
+ // ANSI colors (including brand color #e8683a as RGB)
149
+ const c = {
150
+ reset: '\x1b[0m',
151
+ bold: '\x1b[1m',
152
+ dim: '\x1b[2m',
153
+ green: '\x1b[32m',
154
+ yellow: '\x1b[33m',
155
+ blue: '\x1b[34m',
156
+ cyan: '\x1b[36m',
157
+ red: '\x1b[31m',
158
+ brand: '\x1b[38;2;232;104;58m', // #e8683a - AgileFlow brand orange
159
+ };
160
+
161
+ // Beautiful compact colorful format
162
+ const lines = [];
163
+
164
+ // Header line with project info (brand color name, dim version, colored branch)
165
+ const branchColor = info.git.branch === 'main' ? c.green : c.cyan;
166
+ lines.push(`${c.brand}${c.bold}${info.project.name}${c.reset} ${c.dim}v${info.project.version}${c.reset} | ${branchColor}${info.git.branch}${c.reset} ${c.dim}(${info.git.commit})${c.reset}`);
167
+
168
+ // Status line (yellow WIP, red blocked)
169
+ const wipColor = info.agileflow.wipCount > 0 ? c.yellow : c.dim;
170
+ let statusLine = info.agileflow.wipCount > 0
171
+ ? `${wipColor}WIP: ${info.agileflow.wipCount}${c.reset}`
172
+ : `${c.dim}No active work${c.reset}`;
173
+ if (info.agileflow.blockedCount > 0) {
174
+ statusLine += ` | ${c.red}Blocked: ${info.agileflow.blockedCount}${c.reset}`;
175
+ }
176
+ lines.push(statusLine);
177
+
178
+ // Active story (if any) - just the first one (blue label)
179
+ if (info.agileflow.activeStories.length > 0) {
180
+ const story = info.agileflow.activeStories[0];
181
+ lines.push(`${c.blue}Current:${c.reset} ${story.id} - ${story.title}`);
182
+ }
183
+
184
+ // Last commit (just one, dim)
185
+ if (info.git.recentCommits.length > 0) {
186
+ lines.push(`${c.dim}Last: ${info.git.recentCommits[0]}${c.reset}`);
187
+ }
188
+
189
+ return lines.join('\n');
190
+ }
191
+
192
+ // Main execution
193
+ if (require.main === module) {
194
+ const args = process.argv.slice(2);
195
+ const asJson = args.includes('--json');
196
+ const compact = args.includes('--compact');
197
+
198
+ try {
199
+ const info = getProjectInfo();
200
+ console.log(formatOutput(info, asJson, compact));
201
+ process.exit(0);
202
+ } catch (err) {
203
+ console.error('Error getting environment info:', err.message);
204
+ process.exit(1);
205
+ }
206
+ }
207
+
208
+ // Export for use as module
209
+ module.exports = { getProjectInfo, formatOutput };
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * obtain-context.js
4
+ *
5
+ * Gathers all project context in a single execution for any AgileFlow command or agent.
6
+ * Optionally registers the command/agent for PreCompact context preservation.
7
+ * Outputs structured summary to reduce tool calls and startup time.
8
+ *
9
+ * Usage:
10
+ * node scripts/obtain-context.js # Just gather context
11
+ * node scripts/obtain-context.js babysit # Gather + register 'babysit'
12
+ * node scripts/obtain-context.js mentor # Gather + register 'mentor'
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { execSync } = require('child_process');
18
+
19
+ // Optional: Register command for PreCompact context preservation
20
+ const commandName = process.argv[2];
21
+ if (commandName) {
22
+ const sessionStatePath = 'docs/09-agents/session-state.json';
23
+ if (fs.existsSync(sessionStatePath)) {
24
+ try {
25
+ const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
26
+ state.active_command = { name: commandName, activated_at: new Date().toISOString(), state: {} };
27
+ fs.writeFileSync(sessionStatePath, JSON.stringify(state, null, 2) + '\n');
28
+ } catch (e) {
29
+ // Silently continue if session state can't be updated
30
+ }
31
+ }
32
+ }
33
+
34
+ // ANSI colors
35
+ const C = {
36
+ reset: '\x1b[0m',
37
+ dim: '\x1b[2m',
38
+ bold: '\x1b[1m',
39
+ cyan: '\x1b[36m',
40
+ yellow: '\x1b[33m',
41
+ green: '\x1b[32m',
42
+ red: '\x1b[31m',
43
+ magenta: '\x1b[35m',
44
+ };
45
+
46
+ function safeRead(filePath) {
47
+ try {
48
+ return fs.readFileSync(filePath, 'utf8');
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function safeReadJSON(filePath) {
55
+ try {
56
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function safeLs(dirPath) {
63
+ try {
64
+ return fs.readdirSync(dirPath);
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ function safeExec(cmd) {
71
+ try {
72
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function section(title) {
79
+ console.log(`\n${C.cyan}${C.bold}═══ ${title} ═══${C.reset}`);
80
+ }
81
+
82
+ function subsection(title) {
83
+ console.log(`${C.dim}───${C.reset} ${title}`);
84
+ }
85
+
86
+ // ============================================
87
+ // MAIN CONTEXT GATHERING
88
+ // ============================================
89
+
90
+ const title = commandName ? `AgileFlow Context [${commandName}]` : 'AgileFlow Context';
91
+ console.log(`${C.magenta}${C.bold}${title}${C.reset}`);
92
+ console.log(`${C.dim}Generated: ${new Date().toISOString()}${C.reset}`);
93
+
94
+ // 1. GIT STATUS
95
+ section('Git Status');
96
+ const branch = safeExec('git branch --show-current') || 'unknown';
97
+ const status = safeExec('git status --short') || '';
98
+ const statusLines = status.split('\n').filter(Boolean);
99
+ const lastCommit = safeExec('git log -1 --format="%h %s"') || 'no commits';
100
+
101
+ console.log(`Branch: ${C.green}${branch}${C.reset}`);
102
+ console.log(`Last commit: ${C.dim}${lastCommit}${C.reset}`);
103
+ if (statusLines.length > 0) {
104
+ console.log(`Uncommitted: ${C.yellow}${statusLines.length} file(s)${C.reset}`);
105
+ statusLines.slice(0, 10).forEach(line => console.log(` ${C.dim}${line}${C.reset}`));
106
+ if (statusLines.length > 10) console.log(` ${C.dim}... and ${statusLines.length - 10} more${C.reset}`);
107
+ } else {
108
+ console.log(`Uncommitted: ${C.green}clean${C.reset}`);
109
+ }
110
+
111
+ // 2. STATUS.JSON - Stories & Epics
112
+ section('Stories & Epics');
113
+ const statusJson = safeReadJSON('docs/09-agents/status.json');
114
+ if (statusJson) {
115
+ // Epics summary
116
+ const epics = statusJson.epics || {};
117
+ const epicList = Object.entries(epics);
118
+ if (epicList.length > 0) {
119
+ subsection('Epics');
120
+ epicList.forEach(([id, epic]) => {
121
+ const statusColor = epic.status === 'complete' ? C.green : epic.status === 'active' ? C.yellow : C.dim;
122
+ console.log(` ${id}: ${epic.title} ${statusColor}[${epic.status}]${C.reset}`);
123
+ });
124
+ }
125
+
126
+ // Stories summary by status
127
+ const stories = statusJson.stories || {};
128
+ const storyList = Object.entries(stories);
129
+ const byStatus = {};
130
+ storyList.forEach(([id, story]) => {
131
+ const s = story.status || 'unknown';
132
+ if (!byStatus[s]) byStatus[s] = [];
133
+ byStatus[s].push({ id, ...story });
134
+ });
135
+
136
+ // Priority order for display
137
+ const statusOrder = ['in-progress', 'ready', 'blocked', 'draft', 'in-review', 'done'];
138
+
139
+ subsection('Stories by Status');
140
+ statusOrder.forEach(status => {
141
+ if (byStatus[status] && byStatus[status].length > 0) {
142
+ const color = status === 'in-progress' ? C.yellow :
143
+ status === 'ready' ? C.green :
144
+ status === 'blocked' ? C.red :
145
+ status === 'done' ? C.dim : C.reset;
146
+ console.log(` ${color}${status}${C.reset}: ${byStatus[status].length}`);
147
+ byStatus[status].slice(0, 5).forEach(story => {
148
+ console.log(` ${C.dim}${story.id}: ${story.title}${C.reset}`);
149
+ });
150
+ if (byStatus[status].length > 5) {
151
+ console.log(` ${C.dim}... and ${byStatus[status].length - 5} more${C.reset}`);
152
+ }
153
+ }
154
+ });
155
+
156
+ // Show READY stories prominently (these are actionable)
157
+ if (byStatus['ready'] && byStatus['ready'].length > 0) {
158
+ subsection(`${C.green}⭐ Ready to Implement${C.reset}`);
159
+ byStatus['ready'].forEach(story => {
160
+ console.log(` ${story.id}: ${story.title} (${story.epic || 'no epic'})`);
161
+ });
162
+ }
163
+ } else {
164
+ console.log(`${C.dim}No status.json found${C.reset}`);
165
+ }
166
+
167
+ // 3. SESSION STATE
168
+ section('Session State');
169
+ const sessionState = safeReadJSON('docs/09-agents/session-state.json');
170
+ if (sessionState) {
171
+ const current = sessionState.current_session;
172
+ if (current && current.started_at) {
173
+ const started = new Date(current.started_at);
174
+ const duration = Math.round((Date.now() - started.getTime()) / 60000);
175
+ console.log(`Active session: ${C.green}${duration} min${C.reset}`);
176
+ if (current.current_story) {
177
+ console.log(`Working on: ${C.yellow}${current.current_story}${C.reset}`);
178
+ }
179
+ } else {
180
+ console.log(`${C.dim}No active session${C.reset}`);
181
+ }
182
+
183
+ const last = sessionState.last_session;
184
+ if (last && last.ended_at) {
185
+ console.log(`Last session: ${C.dim}${last.ended_at} (${last.duration_minutes || '?'} min)${C.reset}`);
186
+ if (last.summary) console.log(` Summary: ${C.dim}${last.summary}${C.reset}`);
187
+ }
188
+
189
+ // Active command (for context preservation)
190
+ if (sessionState.active_command) {
191
+ console.log(`Active command: ${C.cyan}${sessionState.active_command.name}${C.reset}`);
192
+ }
193
+ } else {
194
+ console.log(`${C.dim}No session-state.json found${C.reset}`);
195
+ }
196
+
197
+ // 4. DOCS STRUCTURE
198
+ section('Documentation');
199
+ const docsDir = 'docs';
200
+ const docFolders = safeLs(docsDir).filter(f => {
201
+ try {
202
+ return fs.statSync(path.join(docsDir, f)).isDirectory();
203
+ } catch {
204
+ return false;
205
+ }
206
+ });
207
+
208
+ if (docFolders.length > 0) {
209
+ docFolders.forEach(folder => {
210
+ const folderPath = path.join(docsDir, folder);
211
+ const files = safeLs(folderPath);
212
+ const mdFiles = files.filter(f => f.endsWith('.md'));
213
+ const jsonFiles = files.filter(f => f.endsWith('.json') || f.endsWith('.jsonl'));
214
+
215
+ let info = [];
216
+ if (mdFiles.length > 0) info.push(`${mdFiles.length} md`);
217
+ if (jsonFiles.length > 0) info.push(`${jsonFiles.length} json`);
218
+
219
+ console.log(` ${C.dim}${folder}/${C.reset} ${info.length > 0 ? `(${info.join(', ')})` : ''}`);
220
+ });
221
+ }
222
+
223
+ // 5. RESEARCH NOTES
224
+ section('Research Notes');
225
+ const researchDir = 'docs/10-research';
226
+ const researchFiles = safeLs(researchDir).filter(f => f.endsWith('.md') && f !== 'README.md');
227
+ if (researchFiles.length > 0) {
228
+ // Sort by date (filename starts with YYYYMMDD)
229
+ researchFiles.sort().reverse();
230
+ researchFiles.slice(0, 5).forEach(file => {
231
+ console.log(` ${C.dim}${file}${C.reset}`);
232
+ });
233
+ if (researchFiles.length > 5) {
234
+ console.log(` ${C.dim}... and ${researchFiles.length - 5} more${C.reset}`);
235
+ }
236
+ } else {
237
+ console.log(`${C.dim}No research notes${C.reset}`);
238
+ }
239
+
240
+ // 6. BUS MESSAGES (last 5)
241
+ section('Recent Agent Messages');
242
+ const busPath = 'docs/09-agents/bus/log.jsonl';
243
+ const busContent = safeRead(busPath);
244
+ if (busContent) {
245
+ const lines = busContent.trim().split('\n').filter(Boolean);
246
+ const recent = lines.slice(-5);
247
+ if (recent.length > 0) {
248
+ recent.forEach(line => {
249
+ try {
250
+ const msg = JSON.parse(line);
251
+ const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '?';
252
+ console.log(` ${C.dim}[${time}]${C.reset} ${msg.from || '?'}: ${msg.type || msg.message || '?'}`);
253
+ } catch {
254
+ console.log(` ${C.dim}${line.substring(0, 80)}...${C.reset}`);
255
+ }
256
+ });
257
+ } else {
258
+ console.log(`${C.dim}No messages${C.reset}`);
259
+ }
260
+ } else {
261
+ console.log(`${C.dim}No bus log found${C.reset}`);
262
+ }
263
+
264
+ // 7. KEY FILES PRESENCE
265
+ section('Key Files');
266
+ const keyFiles = [
267
+ { path: 'CLAUDE.md', label: 'CLAUDE.md (project instructions)' },
268
+ { path: 'README.md', label: 'README.md (project overview)' },
269
+ { path: 'docs/08-project/roadmap.md', label: 'Roadmap' },
270
+ { path: 'docs/02-practices/README.md', label: 'Practices index' },
271
+ { path: '.claude/settings.json', label: 'Claude settings' },
272
+ ];
273
+
274
+ keyFiles.forEach(({ path: filePath, label }) => {
275
+ const exists = fs.existsSync(filePath);
276
+ const icon = exists ? `${C.green}✓${C.reset}` : `${C.dim}○${C.reset}`;
277
+ console.log(` ${icon} ${label}`);
278
+ });
279
+
280
+ // 8. EPICS FOLDER
281
+ section('Epic Files');
282
+ const epicFiles = safeLs('docs/05-epics').filter(f => f.endsWith('.md') && f !== 'README.md');
283
+ if (epicFiles.length > 0) {
284
+ epicFiles.forEach(file => {
285
+ console.log(` ${C.dim}${file}${C.reset}`);
286
+ });
287
+ } else {
288
+ console.log(`${C.dim}No epic files${C.reset}`);
289
+ }
290
+
291
+ // FOOTER
292
+ console.log(`\n${C.dim}─────────────────────────────────────────${C.reset}`);
293
+ console.log(`${C.dim}Context gathered in single execution. Ready for task selection.${C.reset}\n`);