agileflow 2.99.8 → 3.0.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 (65) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/lib/cache-provider.js +155 -0
  3. package/lib/codebase-indexer.js +1 -1
  4. package/lib/content-sanitizer.js +1 -0
  5. package/lib/dashboard-protocol.js +25 -0
  6. package/lib/dashboard-server.js +184 -133
  7. package/lib/errors.js +18 -0
  8. package/lib/file-cache.js +1 -1
  9. package/lib/flag-detection.js +11 -20
  10. package/lib/git-operations.js +15 -33
  11. package/lib/merge-operations.js +40 -34
  12. package/lib/process-executor.js +199 -0
  13. package/lib/registry-cache.js +13 -47
  14. package/lib/skill-loader.js +206 -0
  15. package/lib/smart-json-file.js +2 -4
  16. package/package.json +1 -1
  17. package/scripts/agileflow-configure.js +13 -12
  18. package/scripts/agileflow-statusline.sh +30 -0
  19. package/scripts/agileflow-welcome.js +181 -212
  20. package/scripts/auto-self-improve.js +3 -3
  21. package/scripts/claude-smart.sh +67 -0
  22. package/scripts/claude-tmux.sh +248 -161
  23. package/scripts/damage-control-multi-agent.js +227 -0
  24. package/scripts/lib/bus-utils.js +471 -0
  25. package/scripts/lib/configure-detect.js +5 -6
  26. package/scripts/lib/configure-features.js +44 -0
  27. package/scripts/lib/configure-repair.js +5 -6
  28. package/scripts/lib/configure-utils.js +2 -3
  29. package/scripts/lib/context-formatter.js +87 -8
  30. package/scripts/lib/damage-control-utils.js +37 -3
  31. package/scripts/lib/file-lock.js +392 -0
  32. package/scripts/lib/ideation-index.js +2 -5
  33. package/scripts/lib/lifecycle-detector.js +123 -0
  34. package/scripts/lib/process-cleanup.js +55 -81
  35. package/scripts/lib/scale-detector.js +357 -0
  36. package/scripts/lib/signal-detectors.js +779 -0
  37. package/scripts/lib/story-state-machine.js +1 -1
  38. package/scripts/lib/sync-ideation-status.js +2 -3
  39. package/scripts/lib/task-registry.js +7 -1
  40. package/scripts/lib/team-events.js +357 -0
  41. package/scripts/messaging-bridge.js +79 -36
  42. package/scripts/migrate-ideation-index.js +37 -14
  43. package/scripts/obtain-context.js +37 -19
  44. package/scripts/ralph-loop.js +3 -4
  45. package/scripts/smart-detect.js +390 -0
  46. package/scripts/team-manager.js +174 -30
  47. package/src/core/commands/audit.md +13 -11
  48. package/src/core/commands/babysit.md +162 -115
  49. package/src/core/commands/changelog.md +21 -4
  50. package/src/core/commands/configure.md +105 -2
  51. package/src/core/commands/debt.md +12 -2
  52. package/src/core/commands/feedback.md +7 -6
  53. package/src/core/commands/ideate/history.md +1 -1
  54. package/src/core/commands/ideate/new.md +5 -5
  55. package/src/core/commands/logic/audit.md +2 -2
  56. package/src/core/commands/pr.md +7 -6
  57. package/src/core/commands/research/analyze.md +28 -20
  58. package/src/core/commands/research/ask.md +43 -0
  59. package/src/core/commands/research/import.md +29 -21
  60. package/src/core/commands/research/list.md +8 -7
  61. package/src/core/commands/research/synthesize.md +356 -20
  62. package/src/core/commands/research/view.md +8 -5
  63. package/src/core/commands/review.md +24 -6
  64. package/src/core/commands/skill/create.md +34 -0
  65. package/tools/cli/lib/docs-setup.js +4 -0
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * signal-detectors.js
4
+ *
5
+ * Registry of feature detector functions for contextual feature routing.
6
+ * Each detector analyzes project signals and returns a recommendation
7
+ * (or null if not triggered).
8
+ *
9
+ * Pattern follows DISCRETION_CONDITIONS from ralph-loop.js:
10
+ * name -> (signals) => result | null
11
+ *
12
+ * Organized by lifecycle phase:
13
+ * pre-story, planning, implementation, post-impl, pre-pr
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // =============================================================================
19
+ // Detector Result Helpers
20
+ // =============================================================================
21
+
22
+ /**
23
+ * Create a recommendation result.
24
+ * @param {string} feature - Feature/command name
25
+ * @param {Object} opts
26
+ * @param {'high'|'medium'|'low'} opts.priority
27
+ * @param {string} opts.trigger - Why this was triggered
28
+ * @param {'auto'|'suggest'|'offer'} opts.action - How to present it
29
+ * @param {string} opts.command - AgileFlow command to run
30
+ * @param {string} opts.phase - Lifecycle phase this belongs to
31
+ * @returns {Object} Recommendation object
32
+ */
33
+ function recommend(feature, opts) {
34
+ return {
35
+ feature,
36
+ priority: opts.priority || 'medium',
37
+ trigger: opts.trigger,
38
+ action: opts.action || 'suggest',
39
+ command: opts.command || `/agileflow:${feature}`,
40
+ phase: opts.phase,
41
+ };
42
+ }
43
+
44
+ // =============================================================================
45
+ // Signal Extraction Helpers
46
+ // =============================================================================
47
+
48
+ function getStoriesByStatus(statusJson, status) {
49
+ if (!statusJson || !statusJson.stories) return [];
50
+ return Object.entries(statusJson.stories)
51
+ .filter(([, s]) => s.status === status)
52
+ .map(([id, s]) => ({ id, ...s }));
53
+ }
54
+
55
+ function getStoriesForEpic(statusJson, epicId) {
56
+ if (!statusJson || !statusJson.stories) return [];
57
+ return Object.entries(statusJson.stories)
58
+ .filter(([, s]) => s.epic === epicId)
59
+ .map(([id, s]) => ({ id, ...s }));
60
+ }
61
+
62
+ function hasPackageScript(packageJson, scriptName) {
63
+ return !!(packageJson && packageJson.scripts && packageJson.scripts[scriptName]);
64
+ }
65
+
66
+ function storyHasAC(story) {
67
+ return !!(
68
+ story &&
69
+ story.acceptance_criteria &&
70
+ Array.isArray(story.acceptance_criteria) &&
71
+ story.acceptance_criteria.length > 0
72
+ );
73
+ }
74
+
75
+ function storyMentions(story, keywords) {
76
+ if (!story) return false;
77
+ const text = `${story.title || ''} ${story.description || ''}`.toLowerCase();
78
+ return keywords.some(kw => text.includes(kw.toLowerCase()));
79
+ }
80
+
81
+ // =============================================================================
82
+ // FEATURE DETECTORS - Organized by Lifecycle Phase
83
+ // =============================================================================
84
+
85
+ /**
86
+ * @typedef {Object} Signals
87
+ * @property {Object} statusJson - Parsed status.json
88
+ * @property {Object} sessionState - Parsed session-state.json
89
+ * @property {Object} metadata - Parsed agileflow-metadata.json
90
+ * @property {Object} git - Git signals { branch, filesChanged, isClean, onFeatureBranch, diffStats }
91
+ * @property {Object} packageJson - Parsed package.json
92
+ * @property {Object} story - Current story { id, status, title, owner, epic, acceptance_criteria }
93
+ * @property {Object} files - File existence checks { tsconfig, eslintrc, coverage, playwright, screenshots }
94
+ * @property {number} storyCount - Total stories in status.json
95
+ * @property {Object} counts - Story counts by status { ready, 'in-progress', blocked, done }
96
+ */
97
+
98
+ const FEATURE_DETECTORS = {
99
+ // =========================================================================
100
+ // PRE-STORY PHASE
101
+ // =========================================================================
102
+
103
+ 'story-validate': (signals) => {
104
+ const { story } = signals;
105
+ if (!story || !story.id) return null;
106
+ if (story.status !== 'ready' && story.status !== 'in-progress') return null;
107
+ if (!storyHasAC(story)) {
108
+ return recommend('story-validate', {
109
+ priority: 'high',
110
+ trigger: `Story ${story.id} missing acceptance criteria`,
111
+ action: 'suggest',
112
+ phase: 'pre-story',
113
+ });
114
+ }
115
+ return null;
116
+ },
117
+
118
+ 'blockers': (signals) => {
119
+ const blocked = getStoriesByStatus(signals.statusJson, 'blocked');
120
+ if (blocked.length === 0) return null;
121
+ return recommend('blockers', {
122
+ priority: 'high',
123
+ trigger: `${blocked.length} blocked story(ies)`,
124
+ action: 'suggest',
125
+ phase: 'pre-story',
126
+ });
127
+ },
128
+
129
+ 'choose': (signals) => {
130
+ const { story, counts } = signals;
131
+ if (story && story.id) return null; // Already have a story
132
+ if ((counts.ready || 0) < 2) return null;
133
+ return recommend('choose', {
134
+ priority: 'medium',
135
+ trigger: `${counts.ready} ready stories - use AI to pick the best one`,
136
+ action: 'offer',
137
+ phase: 'pre-story',
138
+ });
139
+ },
140
+
141
+ 'assign': (signals) => {
142
+ const ready = getStoriesByStatus(signals.statusJson, 'ready');
143
+ const unassigned = ready.filter(s => !s.owner);
144
+ if (unassigned.length === 0) return null;
145
+ return recommend('assign', {
146
+ priority: 'low',
147
+ trigger: `${unassigned.length} ready stories without owner`,
148
+ action: 'offer',
149
+ phase: 'pre-story',
150
+ });
151
+ },
152
+
153
+ 'board': (signals) => {
154
+ const { storyCount } = signals;
155
+ if (!storyCount || storyCount < 5) return null;
156
+ return recommend('board', {
157
+ priority: 'low',
158
+ trigger: `${storyCount} stories tracked - visual board available`,
159
+ action: 'offer',
160
+ phase: 'pre-story',
161
+ });
162
+ },
163
+
164
+ 'sprint': (signals) => {
165
+ const { counts } = signals;
166
+ if ((counts.ready || 0) < 3) return null;
167
+ return recommend('sprint', {
168
+ priority: 'low',
169
+ trigger: `${counts.ready} ready stories - sprint planning available`,
170
+ action: 'offer',
171
+ phase: 'pre-story',
172
+ });
173
+ },
174
+
175
+ 'batch': (signals) => {
176
+ const ready = getStoriesByStatus(signals.statusJson, 'ready');
177
+ if (ready.length < 5) return null;
178
+ // Check if stories share same epic (good batch candidate)
179
+ const epicGroups = {};
180
+ ready.forEach(s => {
181
+ const ep = s.epic || 'none';
182
+ epicGroups[ep] = (epicGroups[ep] || 0) + 1;
183
+ });
184
+ const epicGroupCounts = Object.values(epicGroups);
185
+ if (epicGroupCounts.length === 0) return null;
186
+ const maxGroup = Math.max(...epicGroupCounts);
187
+ if (maxGroup < 3) return null;
188
+ return recommend('batch', {
189
+ priority: 'medium',
190
+ trigger: `${maxGroup} ready stories in same epic - batch processing available`,
191
+ action: 'offer',
192
+ phase: 'pre-story',
193
+ });
194
+ },
195
+
196
+ 'workflow': (signals) => {
197
+ const { metadata } = signals;
198
+ const workflows = metadata?.workflows;
199
+ if (!workflows || Object.keys(workflows).length === 0) return null;
200
+ return recommend('workflow', {
201
+ priority: 'low',
202
+ trigger: `${Object.keys(workflows).length} workflow template(s) configured`,
203
+ action: 'offer',
204
+ phase: 'pre-story',
205
+ });
206
+ },
207
+
208
+ 'template': (signals) => {
209
+ const { story } = signals;
210
+ if (!story || story.status !== 'ready') return null;
211
+ if (!story.title) return null;
212
+ // Suggest template if story is a new doc/pattern type
213
+ if (storyMentions(story, ['template', 'boilerplate', 'scaffold', 'generator'])) {
214
+ return recommend('template', {
215
+ priority: 'low',
216
+ trigger: `Story mentions template/scaffold patterns`,
217
+ action: 'offer',
218
+ phase: 'pre-story',
219
+ });
220
+ }
221
+ return null;
222
+ },
223
+
224
+ 'configure': (signals) => {
225
+ const { metadata } = signals;
226
+ // Only suggest if metadata is minimal/missing
227
+ if (metadata && Object.keys(metadata).length > 3) return null;
228
+ return recommend('configure', {
229
+ priority: 'low',
230
+ trigger: 'Minimal AgileFlow configuration detected',
231
+ action: 'offer',
232
+ phase: 'pre-story',
233
+ });
234
+ },
235
+
236
+ // =========================================================================
237
+ // PLANNING PHASE
238
+ // =========================================================================
239
+
240
+ 'impact': (signals) => {
241
+ const { git, story } = signals;
242
+ if (!story || story.status !== 'in-progress') return null;
243
+ // Suggest impact analysis if touching core/shared files
244
+ const coreFilesChanged = (git.changedFiles || []).filter(f =>
245
+ /^(src\/(core|lib|shared)|lib\/|packages\/.*\/src\/)/.test(f)
246
+ ).length;
247
+ if (coreFilesChanged < (signals.thresholds?.impact_min_files || 3)) return null;
248
+ return recommend('impact', {
249
+ priority: 'high',
250
+ trigger: `${coreFilesChanged} core/shared files being modified`,
251
+ action: 'suggest',
252
+ phase: 'planning',
253
+ });
254
+ },
255
+
256
+ 'adr': (signals) => {
257
+ const { story } = signals;
258
+ if (!story || !story.id) return null;
259
+ if (storyMentions(story, ['architecture', 'redesign', 'migrate', 'replace', 'new system', 'framework'])) {
260
+ return recommend('adr', {
261
+ priority: 'medium',
262
+ trigger: 'Story involves architectural decisions',
263
+ action: 'suggest',
264
+ command: '/agileflow:adr',
265
+ phase: 'planning',
266
+ });
267
+ }
268
+ return null;
269
+ },
270
+
271
+ 'research': (signals) => {
272
+ const { story } = signals;
273
+ if (!story || !story.id) return null;
274
+ if (storyMentions(story, ['research', 'investigate', 'evaluate', 'compare', 'POC', 'proof of concept', 'spike'])) {
275
+ return recommend('research', {
276
+ priority: 'medium',
277
+ trigger: 'Story involves research/investigation',
278
+ action: 'suggest',
279
+ command: '/agileflow:research:ask',
280
+ phase: 'planning',
281
+ });
282
+ }
283
+ return null;
284
+ },
285
+
286
+ 'baseline': (signals) => {
287
+ const { story, files } = signals;
288
+ if (!story || story.status !== 'in-progress') return null;
289
+ if (!files.coverage) return null;
290
+ // Only suggest baseline at start of work (planning phase)
291
+ return recommend('baseline', {
292
+ priority: 'medium',
293
+ trigger: 'Coverage data exists - mark baseline before changes',
294
+ action: 'offer',
295
+ phase: 'planning',
296
+ });
297
+ },
298
+
299
+ 'council': (signals) => {
300
+ const { story } = signals;
301
+ if (!story || !story.id) return null;
302
+ if (storyMentions(story, ['strategic', 'trade-off', 'decision', 'approach', 'architecture'])) {
303
+ return recommend('council', {
304
+ priority: 'low',
305
+ trigger: 'Story involves strategic decision-making',
306
+ action: 'offer',
307
+ phase: 'planning',
308
+ });
309
+ }
310
+ return null;
311
+ },
312
+
313
+ 'multi-expert': (signals) => {
314
+ const { story } = signals;
315
+ if (!story || !story.id) return null;
316
+ if (storyMentions(story, ['complex', 'cross-cutting', 'full-stack', 'multi-domain'])) {
317
+ return recommend('multi-expert', {
318
+ priority: 'low',
319
+ trigger: 'Story involves multiple domains',
320
+ action: 'offer',
321
+ phase: 'planning',
322
+ });
323
+ }
324
+ return null;
325
+ },
326
+
327
+ 'validate-expertise': (signals) => {
328
+ const { files } = signals;
329
+ if (!files.expertiseDir) return null;
330
+ return recommend('validate-expertise', {
331
+ priority: 'low',
332
+ trigger: 'Expertise files exist - validate for drift',
333
+ action: 'offer',
334
+ phase: 'planning',
335
+ });
336
+ },
337
+
338
+ // =========================================================================
339
+ // IMPLEMENTATION PHASE
340
+ // =========================================================================
341
+
342
+ 'verify': (signals) => {
343
+ const { story, tests, git } = signals;
344
+ if (!story || story.status !== 'in-progress') return null;
345
+ if ((git.filesChanged || 0) === 0) return null;
346
+ if (tests.passing === false) {
347
+ return recommend('verify', {
348
+ priority: 'high',
349
+ trigger: 'Tests are failing',
350
+ action: 'suggest',
351
+ phase: 'implementation',
352
+ });
353
+ }
354
+ return null;
355
+ },
356
+
357
+ 'tests': (signals) => {
358
+ const { story, files, packageJson } = signals;
359
+ if (!story || story.status !== 'in-progress') return null;
360
+ if (!hasPackageScript(packageJson, 'test')) {
361
+ return recommend('tests', {
362
+ priority: 'medium',
363
+ trigger: 'No test script found - set up testing infrastructure',
364
+ action: 'suggest',
365
+ phase: 'implementation',
366
+ });
367
+ }
368
+ return null;
369
+ },
370
+
371
+ 'audit': (signals) => {
372
+ const { story, git } = signals;
373
+ if (!story || story.status !== 'in-progress') return null;
374
+ if ((git.filesChanged || 0) < 5) return null;
375
+ return recommend('audit', {
376
+ priority: 'medium',
377
+ trigger: `${git.filesChanged} files changed - audit story completion`,
378
+ action: 'offer',
379
+ command: '/agileflow:audit',
380
+ phase: 'implementation',
381
+ });
382
+ },
383
+
384
+ 'ci': (signals) => {
385
+ const { files } = signals;
386
+ if (files.ciConfig) return null; // Already has CI
387
+ return recommend('ci', {
388
+ priority: 'low',
389
+ trigger: 'No CI configuration detected',
390
+ action: 'offer',
391
+ phase: 'implementation',
392
+ });
393
+ },
394
+
395
+ 'deps': (signals) => {
396
+ const { packageJson } = signals;
397
+ if (!packageJson) return null;
398
+ // Check for outdated or vulnerable deps signal
399
+ const depCount = Object.keys(packageJson.dependencies || {}).length +
400
+ Object.keys(packageJson.devDependencies || {}).length;
401
+ if (depCount < 10) return null;
402
+ return recommend('deps', {
403
+ priority: 'low',
404
+ trigger: `${depCount} dependencies - dependency graph available`,
405
+ action: 'offer',
406
+ phase: 'implementation',
407
+ });
408
+ },
409
+
410
+ 'diagnose': (signals) => {
411
+ const { sessionState } = signals;
412
+ // Detect if there have been recent errors or stuck patterns
413
+ const failCount = sessionState?.failure_count || 0;
414
+ if (failCount < 2) return null;
415
+ return recommend('diagnose', {
416
+ priority: 'high',
417
+ trigger: `${failCount} recent failures detected - run diagnostics`,
418
+ action: 'suggest',
419
+ phase: 'implementation',
420
+ });
421
+ },
422
+
423
+ 'debt': (signals) => {
424
+ const { story } = signals;
425
+ if (!story || !story.id) return null;
426
+ if (storyMentions(story, ['refactor', 'cleanup', 'tech debt', 'legacy', 'deprecat'])) {
427
+ return recommend('debt', {
428
+ priority: 'medium',
429
+ trigger: 'Story involves technical debt work',
430
+ action: 'offer',
431
+ phase: 'implementation',
432
+ });
433
+ }
434
+ return null;
435
+ },
436
+
437
+ 'maintain': (signals) => {
438
+ const { story } = signals;
439
+ if (!story || !story.id) return null;
440
+ if (storyMentions(story, ['maintenance', 'update', 'upgrade', 'patch', 'housekeeping'])) {
441
+ return recommend('maintain', {
442
+ priority: 'low',
443
+ trigger: 'Story involves maintenance work',
444
+ action: 'offer',
445
+ phase: 'implementation',
446
+ });
447
+ }
448
+ return null;
449
+ },
450
+
451
+ 'packages': (signals) => {
452
+ const { story } = signals;
453
+ if (!story || !story.id) return null;
454
+ if (storyMentions(story, ['dependency', 'dependencies', 'package', 'upgrade', 'npm', 'vulnerability'])) {
455
+ return recommend('packages', {
456
+ priority: 'medium',
457
+ trigger: 'Story involves dependency management',
458
+ action: 'offer',
459
+ phase: 'implementation',
460
+ });
461
+ }
462
+ return null;
463
+ },
464
+
465
+ 'deploy': (signals) => {
466
+ const { story } = signals;
467
+ if (!story || !story.id) return null;
468
+ if (storyMentions(story, ['deploy', 'deployment', 'CD', 'pipeline', 'staging', 'production'])) {
469
+ return recommend('deploy', {
470
+ priority: 'medium',
471
+ trigger: 'Story involves deployment',
472
+ action: 'offer',
473
+ phase: 'implementation',
474
+ });
475
+ }
476
+ return null;
477
+ },
478
+
479
+ 'serve': (signals) => {
480
+ const { metadata } = signals;
481
+ const dashboardEnabled = metadata?.features?.dashboard?.enabled;
482
+ if (!dashboardEnabled) return null;
483
+ return recommend('serve', {
484
+ priority: 'low',
485
+ trigger: 'Dashboard server available',
486
+ action: 'offer',
487
+ phase: 'implementation',
488
+ });
489
+ },
490
+
491
+ // =========================================================================
492
+ // POST-IMPLEMENTATION PHASE
493
+ // =========================================================================
494
+
495
+ 'review': (signals) => {
496
+ const { git, story } = signals;
497
+ if (!story || story.status !== 'in-progress') return null;
498
+ const linesChanged = (git.diffStats?.insertions || 0) + (git.diffStats?.deletions || 0);
499
+ if (linesChanged < (signals.thresholds?.review_min_lines || 100)) return null;
500
+ return recommend('review', {
501
+ priority: 'high',
502
+ trigger: `${linesChanged} lines changed - code review recommended`,
503
+ action: 'suggest',
504
+ phase: 'post-impl',
505
+ });
506
+ },
507
+
508
+ 'logic-audit': (signals) => {
509
+ const { git, story } = signals;
510
+ if (!story || story.status !== 'in-progress') return null;
511
+ // Suggest logic audit for complex changes
512
+ const coreFiles = (git.changedFiles || []).filter(f =>
513
+ /\.(js|ts|jsx|tsx|py|go|rs)$/.test(f)
514
+ ).length;
515
+ if (coreFiles < 3) return null;
516
+ return recommend('logic-audit', {
517
+ priority: 'medium',
518
+ trigger: `${coreFiles} source files modified - logic audit available`,
519
+ action: 'offer',
520
+ command: '/agileflow:logic:audit',
521
+ phase: 'post-impl',
522
+ });
523
+ },
524
+
525
+ 'docs': (signals) => {
526
+ const { git, story } = signals;
527
+ if (!story || story.status !== 'in-progress') return null;
528
+ // Detect API or public interface changes
529
+ const apiFiles = (git.changedFiles || []).filter(f =>
530
+ /\b(api|route|endpoint|handler|controller|schema)\b/i.test(f)
531
+ ).length;
532
+ if (apiFiles === 0) return null;
533
+ return recommend('docs', {
534
+ priority: 'medium',
535
+ trigger: `${apiFiles} API/interface files changed - docs sync recommended`,
536
+ action: 'suggest',
537
+ phase: 'post-impl',
538
+ });
539
+ },
540
+
541
+ 'changelog': (signals) => {
542
+ const { git } = signals;
543
+ // Suggest changelog if there are multiple commits on feature branch
544
+ if (!git.onFeatureBranch) return null;
545
+ if ((git.commitCount || 0) < 3) return null;
546
+ return recommend('changelog', {
547
+ priority: 'low',
548
+ trigger: `${git.commitCount} commits on feature branch - changelog entry recommended`,
549
+ action: 'offer',
550
+ phase: 'post-impl',
551
+ });
552
+ },
553
+
554
+ 'metrics': (signals) => {
555
+ const { statusJson } = signals;
556
+ if (!statusJson || !statusJson.stories) return null;
557
+ const doneCount = getStoriesByStatus(statusJson, 'done').length;
558
+ if (doneCount < 5) return null;
559
+ return recommend('metrics', {
560
+ priority: 'low',
561
+ trigger: `${doneCount} completed stories - metrics dashboard available`,
562
+ action: 'offer',
563
+ phase: 'post-impl',
564
+ });
565
+ },
566
+
567
+ 'retro': (signals) => {
568
+ const { statusJson } = signals;
569
+ if (!statusJson || !statusJson.epics) return null;
570
+ // Suggest retro when an epic is mostly complete
571
+ const epics = statusJson.epics || {};
572
+ for (const [epId, ep] of Object.entries(epics)) {
573
+ if (!ep) continue;
574
+ if (ep.status === 'done' || ep.progress >= 90) {
575
+ return recommend('retro', {
576
+ priority: 'medium',
577
+ trigger: `Epic ${epId} is ${ep.status === 'done' ? 'complete' : `${ep.progress ?? 0}% done`} - retrospective recommended`,
578
+ action: 'offer',
579
+ phase: 'post-impl',
580
+ });
581
+ }
582
+ }
583
+ return null;
584
+ },
585
+
586
+ 'velocity': (signals) => {
587
+ const { statusJson } = signals;
588
+ if (!statusJson || !statusJson.stories) return null;
589
+ const doneCount = getStoriesByStatus(statusJson, 'done').length;
590
+ if (doneCount < 10) return null;
591
+ return recommend('velocity', {
592
+ priority: 'low',
593
+ trigger: `${doneCount} completed stories - velocity tracking available`,
594
+ action: 'offer',
595
+ phase: 'post-impl',
596
+ });
597
+ },
598
+
599
+ 'readme-sync': (signals) => {
600
+ const { git } = signals;
601
+ // Check if any README files were potentially affected
602
+ const readmeAffected = (git.changedFiles || []).some(f =>
603
+ /readme/i.test(f) || /^(src|packages|apps)\/[^/]+\//.test(f)
604
+ );
605
+ if (!readmeAffected) return null;
606
+ return recommend('readme-sync', {
607
+ priority: 'low',
608
+ trigger: 'Structural changes detected - README sync available',
609
+ action: 'offer',
610
+ phase: 'post-impl',
611
+ });
612
+ },
613
+
614
+ 'feedback': (signals) => {
615
+ const { sessionState } = signals;
616
+ // Suggest feedback collection after extended sessions
617
+ const sessionDuration = sessionState?.current_session?.started_at
618
+ ? Math.round((Date.now() - new Date(sessionState.current_session.started_at).getTime()) / 60000)
619
+ : 0;
620
+ if (isNaN(sessionDuration) || sessionDuration < 30) return null;
621
+ return recommend('feedback', {
622
+ priority: 'low',
623
+ trigger: `${sessionDuration}min session - consider capturing feedback`,
624
+ action: 'offer',
625
+ phase: 'post-impl',
626
+ });
627
+ },
628
+
629
+ // =========================================================================
630
+ // PRE-PR PHASE
631
+ // =========================================================================
632
+
633
+ 'pr': (signals) => {
634
+ const { git, tests, story } = signals;
635
+ if (!story || story.status !== 'in-progress') return null;
636
+ if (!git.onFeatureBranch) return null;
637
+ if (tests.passing !== true) return null;
638
+ return recommend('pr', {
639
+ priority: 'high',
640
+ trigger: 'Tests passing on feature branch - ready for PR',
641
+ action: 'suggest',
642
+ phase: 'pre-pr',
643
+ });
644
+ },
645
+
646
+ 'compress': (signals) => {
647
+ const { statusJson } = signals;
648
+ if (!statusJson || !statusJson.stories) return null;
649
+ const totalStories = Object.keys(statusJson.stories).length;
650
+ if (totalStories < (signals.thresholds?.compress_min_stories || 100)) return null;
651
+ return recommend('compress', {
652
+ priority: 'medium',
653
+ trigger: `${totalStories} stories in status.json - compression recommended`,
654
+ action: 'suggest',
655
+ phase: 'pre-pr',
656
+ });
657
+ },
658
+ };
659
+
660
+ // =============================================================================
661
+ // Phase Mapping (which detectors belong to which phase)
662
+ // =============================================================================
663
+
664
+ const PHASE_MAP = {
665
+ 'pre-story': [
666
+ 'story-validate', 'blockers', 'choose', 'assign', 'board',
667
+ 'sprint', 'batch', 'workflow', 'template', 'configure',
668
+ ],
669
+ 'planning': [
670
+ 'impact', 'adr', 'research', 'baseline', 'council',
671
+ 'multi-expert', 'validate-expertise',
672
+ ],
673
+ 'implementation': [
674
+ 'verify', 'tests', 'audit', 'ci', 'deps',
675
+ 'diagnose', 'debt', 'maintain', 'packages', 'deploy', 'serve',
676
+ ],
677
+ 'post-impl': [
678
+ 'review', 'logic-audit', 'docs', 'changelog', 'metrics',
679
+ 'retro', 'velocity', 'readme-sync', 'feedback',
680
+ ],
681
+ 'pre-pr': [
682
+ 'pr', 'compress',
683
+ ],
684
+ };
685
+
686
+ /**
687
+ * Get all detector names.
688
+ * @returns {string[]}
689
+ */
690
+ function getDetectorNames() {
691
+ return Object.keys(FEATURE_DETECTORS);
692
+ }
693
+
694
+ /**
695
+ * Get detectors for a specific phase.
696
+ * @param {string} phase
697
+ * @returns {string[]}
698
+ */
699
+ function getDetectorsForPhase(phase) {
700
+ return PHASE_MAP[phase] || [];
701
+ }
702
+
703
+ /**
704
+ * Run a single detector by name.
705
+ * @param {string} name - Detector name
706
+ * @param {Signals} signals - Project signals
707
+ * @returns {Object|null} Recommendation or null
708
+ */
709
+ function runDetector(name, signals) {
710
+ const detector = FEATURE_DETECTORS[name];
711
+ if (!detector) return null;
712
+ try {
713
+ return detector(signals);
714
+ } catch {
715
+ return null;
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Run all detectors for given phases.
721
+ * @param {string[]} phases - Array of phase names
722
+ * @param {Signals} signals - Project signals
723
+ * @returns {Object[]} Array of recommendations
724
+ */
725
+ function runDetectorsForPhases(phases, signals) {
726
+ const results = [];
727
+ const seen = new Set();
728
+
729
+ for (const phase of phases) {
730
+ const detectorNames = PHASE_MAP[phase] || [];
731
+ for (const name of detectorNames) {
732
+ if (seen.has(name)) continue;
733
+ seen.add(name);
734
+ const result = runDetector(name, signals);
735
+ if (result) {
736
+ results.push(result);
737
+ }
738
+ }
739
+ }
740
+
741
+ return results;
742
+ }
743
+
744
+ /**
745
+ * Run all detectors (all phases).
746
+ * @param {Signals} signals - Project signals
747
+ * @returns {Object[]} Array of all triggered recommendations
748
+ */
749
+ function runAllDetectors(signals) {
750
+ const results = [];
751
+ for (const [name, detector] of Object.entries(FEATURE_DETECTORS)) {
752
+ try {
753
+ const result = detector(signals);
754
+ if (result) {
755
+ results.push(result);
756
+ }
757
+ } catch {
758
+ // Skip failed detectors
759
+ }
760
+ }
761
+ return results;
762
+ }
763
+
764
+ module.exports = {
765
+ FEATURE_DETECTORS,
766
+ PHASE_MAP,
767
+ recommend,
768
+ getDetectorNames,
769
+ getDetectorsForPhase,
770
+ runDetector,
771
+ runDetectorsForPhases,
772
+ runAllDetectors,
773
+ // Helpers exported for testing
774
+ getStoriesByStatus,
775
+ getStoriesForEpic,
776
+ hasPackageScript,
777
+ storyHasAC,
778
+ storyMentions,
779
+ };