prjct-cli 1.7.5 → 1.8.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.
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs/promises'
13
13
  import path from 'node:path'
14
14
  import { outcomeAnalyzer } from '../outcomes'
15
+ import type { CommandContextEntry } from '../schemas/command-context'
15
16
  import { queueStorage, stateStorage } from '../storage'
16
17
  import type {
17
18
  LearnedPatterns,
@@ -28,6 +29,7 @@ import type {
28
29
  import { getErrorMessage, isNotFoundError } from '../types/fs'
29
30
  import { fileExists } from '../utils/fs-helpers'
30
31
  import { PACKAGE_ROOT } from '../utils/version'
32
+ import { loadCommandContextConfig, resolveCommandContextFull } from './command-context'
31
33
  import {
32
34
  DEFAULT_BUDGETS,
33
35
  filterSkillsByDomains,
@@ -144,18 +146,14 @@ class PromptBuilder {
144
146
 
145
147
  /**
146
148
  * Get additional modules needed for SMART commands (PRJ-94)
147
- * Returns array of module names that should be injected
149
+ * Now config-driven via command-context.config.json (PRJ-298)
148
150
  */
149
- getModulesForCommand(commandName: string): string[] {
150
- const smartCommands: Record<string, string[]> = {
151
- task: ['CLAUDE-intelligence.md', 'CLAUDE-storage.md'],
152
- ship: ['CLAUDE-intelligence.md', 'CLAUDE-storage.md'],
153
- bug: ['CLAUDE-intelligence.md'],
154
- done: ['CLAUDE-storage.md'],
155
- work: ['CLAUDE-intelligence.md', 'CLAUDE-storage.md'],
156
- spec: ['CLAUDE-intelligence.md'],
157
- }
158
- return smartCommands[commandName] || []
151
+ getModulesForCommand(_commandName: string, commandContext?: CommandContextEntry): string[] {
152
+ if (commandContext) {
153
+ return commandContext.modules
154
+ }
155
+ // Fallback if called without config (shouldn't happen after PRJ-298)
156
+ return []
159
157
  }
160
158
 
161
159
  /**
@@ -411,21 +409,20 @@ class PromptBuilder {
411
409
  // Store context for use in helper methods
412
410
  this._currentContext = context
413
411
 
414
- // Agent assignment (CONDITIONAL - only for code-modifying commands)
412
+ // PRJ-298: Config-driven command context (replaces 4 hardcoded lists)
415
413
  const commandName = template.frontmatter?.name?.replace('p:', '') || ''
416
- const agentCommands = [
417
- 'now',
418
- 'build',
419
- 'feature',
420
- 'design',
421
- 'fix',
422
- 'bug',
423
- 'test',
424
- 'work',
425
- 'cleanup',
426
- 'spec',
427
- ]
428
- const needsAgent = agentCommands.includes(commandName)
414
+ let commandContext: CommandContextEntry
415
+ try {
416
+ const config = await loadCommandContextConfig()
417
+ const resolved = resolveCommandContextFull(config, commandName, template)
418
+ commandContext = resolved.entry
419
+ } catch {
420
+ // Fallback: sensible defaults if config fails to load
421
+ commandContext = { agents: true, patterns: true, checklist: false, modules: [] }
422
+ }
423
+
424
+ // Agent assignment (config-driven)
425
+ const needsAgent = commandContext.agents
429
426
 
430
427
  if (agent && needsAgent) {
431
428
  parts.push(`# AGENT: ${agent.name}\n`)
@@ -591,21 +588,8 @@ class PromptBuilder {
591
588
  )
592
589
  }
593
590
 
594
- // OPTIMIZED: Only include patterns for code-modifying commands
595
- const codeCommands = [
596
- 'now',
597
- 'build',
598
- 'feature',
599
- 'design',
600
- 'cleanup',
601
- 'fix',
602
- 'bug',
603
- 'test',
604
- 'init',
605
- 'spec',
606
- 'work',
607
- ]
608
- const needsPatterns = codeCommands.includes(commandName)
591
+ // OPTIMIZED: Only include patterns for code-modifying commands (config-driven, PRJ-298)
592
+ const needsPatterns = commandContext.patterns
609
593
 
610
594
  // Include code patterns analysis for code-modifying commands
611
595
  const codePatternsContent = state?.codePatterns || ''
@@ -636,8 +620,8 @@ class PromptBuilder {
636
620
  // CRITICAL: Compressed rules
637
621
  parts.push(this.buildCriticalRules())
638
622
 
639
- // PRJ-94: Inject additional modules for SMART commands
640
- const additionalModules = this.getModulesForCommand(commandName)
623
+ // PRJ-94/PRJ-298: Inject additional modules for SMART commands (config-driven)
624
+ const additionalModules = this.getModulesForCommand(commandName, commandContext)
641
625
  if (additionalModules.length > 0) {
642
626
  for (const moduleName of additionalModules) {
643
627
  const moduleContent = await this.loadModule(moduleName)
@@ -698,19 +682,8 @@ class PromptBuilder {
698
682
  )
699
683
  }
700
684
 
701
- // P4.1: Quality Checklists
702
- const checklistCommands = [
703
- 'now',
704
- 'build',
705
- 'feature',
706
- 'design',
707
- 'fix',
708
- 'bug',
709
- 'cleanup',
710
- 'spec',
711
- 'work',
712
- ]
713
- if (checklistCommands.includes(commandName)) {
685
+ // P4.1: Quality Checklists (config-driven, PRJ-298)
686
+ if (commandContext.checklist) {
714
687
  const routing = await this.loadChecklistRouting()
715
688
  const checklists = await this.loadChecklists()
716
689
 
@@ -176,6 +176,23 @@ export const COMMANDS: CommandMeta[] = [
176
176
  'Per-package CLAUDE.md generation',
177
177
  ],
178
178
  },
179
+ {
180
+ name: 'perf',
181
+ group: 'core',
182
+ description: 'Performance dashboard - startup, memory, context, and handoff metrics',
183
+ usage: { claude: '/p:perf', terminal: 'prjct perf [days]' },
184
+ params: '[days]',
185
+ implemented: true,
186
+ hasTemplate: false,
187
+ requiresProject: true,
188
+ features: [
189
+ 'Startup time tracking',
190
+ 'Memory usage snapshots',
191
+ 'Context correctness rate',
192
+ 'Subtask handoff rate',
193
+ 'Command duration breakdown',
194
+ ],
195
+ },
179
196
  {
180
197
  name: 'suggest',
181
198
  group: 'core',
@@ -27,6 +27,7 @@ import { AnalysisCommands } from './analysis'
27
27
  import { AnalyticsCommands } from './analytics'
28
28
  import { ContextCommands } from './context'
29
29
  import { MaintenanceCommands } from './maintenance'
30
+ import { PerformanceCommands } from './performance'
30
31
  import { PlanningCommands } from './planning'
31
32
  import { SetupCommands } from './setup'
32
33
  import { ShippingCommands } from './shipping'
@@ -42,6 +43,7 @@ class PrjctCommands {
42
43
  private planning: PlanningCommands
43
44
  private shipping: ShippingCommands
44
45
  private analytics: AnalyticsCommands
46
+ private performanceCmds: PerformanceCommands
45
47
  private maintenance: MaintenanceCommands
46
48
  private analysis: AnalysisCommands
47
49
  private setupCmds: SetupCommands
@@ -58,6 +60,7 @@ class PrjctCommands {
58
60
  this.planning = new PlanningCommands()
59
61
  this.shipping = new ShippingCommands()
60
62
  this.analytics = new AnalyticsCommands()
63
+ this.performanceCmds = new PerformanceCommands()
61
64
  this.maintenance = new MaintenanceCommands()
62
65
  this.analysis = new AnalysisCommands()
63
66
  this.setupCmds = new SetupCommands()
@@ -140,6 +143,12 @@ class PrjctCommands {
140
143
  return this.analytics.help(topic, projectPath)
141
144
  }
142
145
 
146
+ // ========== Performance Commands ==========
147
+
148
+ async perf(period: string = '7', projectPath: string = process.cwd()): Promise<CommandResult> {
149
+ return this.performanceCmds.perf(period, projectPath)
150
+ }
151
+
143
152
  // ========== Maintenance Commands ==========
144
153
 
145
154
  async cleanup(
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Performance Commands: perf
3
+ * Dashboard for CLI performance metrics
4
+ *
5
+ * @see PRJ-297
6
+ */
7
+
8
+ import chalk from 'chalk'
9
+ import performanceTracker from '../infrastructure/performance-tracker'
10
+ import type { CommandResult } from '../types'
11
+ import { getErrorMessage } from '../types/fs'
12
+ import { configManager, out, PrjctCommandsBase } from './base'
13
+
14
+ // Target thresholds for display
15
+ const TARGETS = {
16
+ startup: { max: 500, unit: 'ms' },
17
+ heapMB: { max: 80, unit: 'MB' },
18
+ contextRate: { min: 100, unit: '%' },
19
+ handoffRate: { min: 100, unit: '%' },
20
+ }
21
+
22
+ function statusIcon(value: number, target: number, mode: 'below' | 'above'): string {
23
+ if (mode === 'below') {
24
+ return value <= target ? chalk.green('✓') : chalk.yellow('⚠')
25
+ }
26
+ return value >= target ? chalk.green('✓') : chalk.yellow('⚠')
27
+ }
28
+
29
+ export class PerformanceCommands extends PrjctCommandsBase {
30
+ /**
31
+ * prjct perf - Performance dashboard
32
+ */
33
+ async perf(period: string = '7', projectPath: string = process.cwd()): Promise<CommandResult> {
34
+ try {
35
+ const initResult = await this.ensureProjectInit(projectPath)
36
+ if (!initResult.success) return initResult
37
+
38
+ const projectId = await configManager.getProjectId(projectPath)
39
+ if (!projectId) {
40
+ out.failWithHint('NO_PROJECT_ID')
41
+ return { success: false, error: 'No project ID found' }
42
+ }
43
+
44
+ const days = parseInt(period, 10) || 7
45
+ const report = await performanceTracker.getReport(projectId, days)
46
+
47
+ const hasData =
48
+ report.startup ||
49
+ report.memory ||
50
+ report.contextCorrectness ||
51
+ report.subtaskHandoff ||
52
+ report.commandDurations
53
+
54
+ if (!hasData) {
55
+ console.log(`\n${chalk.dim('No performance data yet.')}`)
56
+ console.log(`${chalk.dim('Metrics are collected automatically as you use the CLI.')}\n`)
57
+ return { success: true, message: 'No data' }
58
+ }
59
+
60
+ console.log(`\n${chalk.cyan('Performance Report')} ${chalk.dim(`(last ${days} days)`)}`)
61
+ console.log('═'.repeat(55))
62
+
63
+ // Startup time
64
+ if (report.startup) {
65
+ const icon = statusIcon(report.startup.avg, TARGETS.startup.max, 'below')
66
+ console.log(
67
+ ` Startup: avg ${chalk.bold(`${report.startup.avg}ms`)} ${chalk.dim(`(min ${report.startup.min}, max ${report.startup.max}, n=${report.startup.count})`)} ${icon} ${chalk.dim(`target: <${TARGETS.startup.max}ms`)}`
68
+ )
69
+ }
70
+
71
+ // Memory
72
+ if (report.memory) {
73
+ const icon = statusIcon(report.memory.peakHeapMB, TARGETS.heapMB.max, 'below')
74
+ console.log(
75
+ ` Memory: avg ${chalk.bold(`${report.memory.avgHeapMB}MB`)} heap, peak ${report.memory.peakHeapMB}MB, rss ${report.memory.avgRssMB}MB ${icon} ${chalk.dim(`target: <${TARGETS.heapMB.max}MB`)}`
76
+ )
77
+ }
78
+
79
+ // Context correctness
80
+ if (report.contextCorrectness) {
81
+ const icon = statusIcon(report.contextCorrectness.rate, TARGETS.contextRate.min, 'above')
82
+ console.log(
83
+ ` Context: ${chalk.bold(`${report.contextCorrectness.rate}%`)} tasks received sync ${chalk.dim(`(${report.contextCorrectness.receivedSync}/${report.contextCorrectness.total})`)} ${icon} ${chalk.dim(`target: ${TARGETS.contextRate.min}%`)}`
84
+ )
85
+ }
86
+
87
+ // Subtask handoff
88
+ if (report.subtaskHandoff) {
89
+ const icon = statusIcon(report.subtaskHandoff.rate, TARGETS.handoffRate.min, 'above')
90
+ console.log(
91
+ ` Handoff: ${chalk.bold(`${report.subtaskHandoff.rate}%`)} subtasks with output ${chalk.dim(`(${report.subtaskHandoff.outputPopulated}/${report.subtaskHandoff.total})`)} ${icon} ${chalk.dim(`target: ${TARGETS.handoffRate.min}%`)}`
92
+ )
93
+ }
94
+
95
+ // Command durations
96
+ if (report.commandDurations && Object.keys(report.commandDurations).length > 0) {
97
+ console.log(`\n ${chalk.dim('Command Durations:')}`)
98
+ for (const [cmd, summary] of Object.entries(report.commandDurations)) {
99
+ console.log(
100
+ ` ${cmd.padEnd(12)} avg ${chalk.bold(`${summary.avg}ms`)} ${chalk.dim(`(min ${summary.min}, max ${summary.max}, n=${summary.count})`)}`
101
+ )
102
+ }
103
+ }
104
+
105
+ console.log('═'.repeat(55))
106
+ console.log('')
107
+
108
+ return { success: true }
109
+ } catch (error) {
110
+ out.fail(getErrorMessage(error))
111
+ return { success: false, error: getErrorMessage(error) }
112
+ }
113
+ }
114
+ }
@@ -13,6 +13,7 @@ import { AnalyticsCommands } from './analytics'
13
13
  import { CATEGORIES, COMMANDS } from './command-data'
14
14
  import { ContextCommands } from './context'
15
15
  import { MaintenanceCommands } from './maintenance'
16
+ import { PerformanceCommands } from './performance'
16
17
  import { PlanningCommands } from './planning'
17
18
  import { commandRegistry } from './registry'
18
19
  import { SetupCommands } from './setup'
@@ -25,6 +26,7 @@ const workflow = new WorkflowCommands()
25
26
  const planning = new PlanningCommands()
26
27
  const shipping = new ShippingCommands()
27
28
  const analytics = new AnalyticsCommands()
29
+ const performance = new PerformanceCommands()
28
30
  const maintenance = new MaintenanceCommands()
29
31
  const analysis = new AnalysisCommands()
30
32
  const setup = new SetupCommands()
@@ -73,6 +75,9 @@ export function registerAllCommands(): void {
73
75
  commandRegistry.registerMethod('dash', analytics, 'dash', getMeta('dash'))
74
76
  commandRegistry.registerMethod('help', analytics, 'help', getMeta('help'))
75
77
 
78
+ // Performance commands
79
+ commandRegistry.registerMethod('perf', performance, 'perf', getMeta('perf'))
80
+
76
81
  // Maintenance commands
77
82
  commandRegistry.registerMethod('cleanup', maintenance, 'cleanup', getMeta('cleanup'))
78
83
  commandRegistry.registerMethod('design', maintenance, 'design', getMeta('design'))
@@ -105,6 +110,7 @@ export {
105
110
  planning,
106
111
  shipping,
107
112
  analytics,
113
+ performance,
108
114
  maintenance,
109
115
  analysis,
110
116
  setup,
@@ -10,7 +10,14 @@
10
10
 
11
11
  import commandExecutor from '../agentic/command-executor'
12
12
  import { templateExecutor } from '../agentic/template-executor'
13
+ import {
14
+ type FibonacciPoint,
15
+ isValidPoint,
16
+ pointsToMinutes,
17
+ pointsToTimeRange,
18
+ } from '../domain/fibonacci'
13
19
  import { linearService } from '../integrations/linear'
20
+ import outcomeRecorder from '../outcomes/recorder'
14
21
  import { generateUUID } from '../schemas'
15
22
  import { queueStorage, stateStorage } from '../storage'
16
23
  import type { CommandResult } from '../types'
@@ -132,6 +139,20 @@ export class WorkflowCommands extends PrjctCommandsBase {
132
139
  task,
133
140
  agenticMode: true,
134
141
  availableAgents,
142
+ // Fibonacci estimation helpers for templates
143
+ fibonacci: {
144
+ isValidPoint,
145
+ pointsToMinutes,
146
+ pointsToTimeRange,
147
+ storeEstimate: async (points: FibonacciPoint) => {
148
+ const minutes = pointsToMinutes(points)
149
+ await stateStorage.updateCurrentTask(projectId, {
150
+ estimatedPoints: points,
151
+ estimatedMinutes: minutes.typical,
152
+ })
153
+ return minutes
154
+ },
155
+ },
135
156
  }
136
157
  } else {
137
158
  // Read from storage (JSON is source of truth)
@@ -187,9 +208,44 @@ export class WorkflowCommands extends PrjctCommandsBase {
187
208
 
188
209
  const task = currentTask.description
189
210
  let duration = ''
211
+ let actualMinutes = 0
190
212
  if (currentTask.startedAt) {
191
213
  const started = new Date(currentTask.startedAt)
192
214
  duration = dateHelper.calculateDuration(started)
215
+ actualMinutes = Math.round((Date.now() - started.getTime()) / 60_000)
216
+ }
217
+
218
+ // Record outcome with estimation data if available
219
+ const estimatedMinutes = (currentTask as { estimatedMinutes?: number }).estimatedMinutes
220
+ const estimatedPoints = (currentTask as { estimatedPoints?: number }).estimatedPoints
221
+ try {
222
+ await outcomeRecorder.record(projectId, {
223
+ sessionId: currentTask.sessionId,
224
+ command: 'done',
225
+ task,
226
+ startedAt: currentTask.startedAt,
227
+ completedAt: dateHelper.getTimestamp(),
228
+ estimatedDuration: estimatedMinutes ? formatMinutesToDuration(estimatedMinutes) : '0m',
229
+ actualDuration: duration || '0m',
230
+ variance: estimatedMinutes ? formatVariance(actualMinutes - estimatedMinutes) : '+0m',
231
+ completedAsPlanned: true,
232
+ qualityScore: 3,
233
+ tags: [(currentTask as { linearId?: string }).linearId].filter(Boolean) as string[],
234
+ })
235
+ } catch {
236
+ // Outcome recording failure should not block workflow
237
+ }
238
+
239
+ // Build variance display
240
+ let varianceDisplay = ''
241
+ if (estimatedPoints && estimatedMinutes) {
242
+ const diff = actualMinutes - estimatedMinutes
243
+ const pct =
244
+ estimatedMinutes > 0
245
+ ? Math.round(((actualMinutes - estimatedMinutes) / estimatedMinutes) * 100)
246
+ : 0
247
+ const sign = diff >= 0 ? '+' : ''
248
+ varianceDisplay = ` | est: ${estimatedPoints}pt (${formatMinutesToDuration(estimatedMinutes)}) → ${sign}${pct}%`
193
249
  }
194
250
 
195
251
  // Write-through: Complete task (JSON → MD → Event)
@@ -204,16 +260,16 @@ export class WorkflowCommands extends PrjctCommandsBase {
204
260
  if (apiKey && creds.linear?.teamId) {
205
261
  await linearService.initializeFromApiKey(apiKey, creds.linear.teamId)
206
262
  await linearService.markDone(linearId)
207
- out.done(`${task}${duration ? ` (${duration})` : ''} → Linear ✓`)
263
+ out.done(`${task}${duration ? ` (${duration}${varianceDisplay})` : ''} → Linear ✓`)
208
264
  } else {
209
- out.done(`${task}${duration ? ` (${duration})` : ''}`)
265
+ out.done(`${task}${duration ? ` (${duration}${varianceDisplay})` : ''}`)
210
266
  }
211
267
  } catch {
212
268
  // Linear sync failed silently - don't block the workflow
213
- out.done(`${task}${duration ? ` (${duration})` : ''}`)
269
+ out.done(`${task}${duration ? ` (${duration}${varianceDisplay})` : ''}`)
214
270
  }
215
271
  } else {
216
- out.done(`${task}${duration ? ` (${duration})` : ''}`)
272
+ out.done(`${task}${duration ? ` (${duration}${varianceDisplay})` : ''}`)
217
273
  }
218
274
  showStateInfo('completed')
219
275
  showNextSteps('done')
@@ -221,6 +277,9 @@ export class WorkflowCommands extends PrjctCommandsBase {
221
277
  await this.logToMemory(projectPath, 'task_completed', {
222
278
  task,
223
279
  duration,
280
+ estimatedPoints,
281
+ estimatedMinutes,
282
+ actualMinutes,
224
283
  timestamp: dateHelper.getTimestamp(),
225
284
  })
226
285
 
@@ -417,3 +476,27 @@ export class WorkflowCommands extends PrjctCommandsBase {
417
476
  }
418
477
  }
419
478
  }
479
+
480
+ // =============================================================================
481
+ // Helpers
482
+ // =============================================================================
483
+
484
+ /** Format minutes to a human-readable duration string */
485
+ function formatMinutesToDuration(minutes: number): string {
486
+ if (minutes < 60) return `${minutes}m`
487
+ const hours = Math.floor(minutes / 60)
488
+ const mins = minutes % 60
489
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
490
+ }
491
+
492
+ /** Format a variance in minutes to a string like "+30m" or "-15m" */
493
+ function formatVariance(diffMinutes: number): string {
494
+ const sign = diffMinutes >= 0 ? '+' : '-'
495
+ const abs = Math.abs(diffMinutes)
496
+ if (abs >= 60) {
497
+ const hours = Math.floor(abs / 60)
498
+ const mins = abs % 60
499
+ return mins > 0 ? `${sign}${hours}h ${mins}m` : `${sign}${hours}h`
500
+ }
501
+ return `${sign}${abs}m`
502
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "description": "Maps commands to context sections for prompt injection. Wildcard * ensures new commands get sensible defaults instead of zero context.",
4
+ "commands": {
5
+ "now": { "agents": true, "patterns": true, "checklist": true, "modules": [] },
6
+ "build": { "agents": true, "patterns": true, "checklist": true, "modules": [] },
7
+ "feature": { "agents": true, "patterns": true, "checklist": true, "modules": [] },
8
+ "design": { "agents": true, "patterns": true, "checklist": true, "modules": [] },
9
+ "fix": { "agents": true, "patterns": true, "checklist": true, "modules": [] },
10
+ "bug": {
11
+ "agents": true,
12
+ "patterns": true,
13
+ "checklist": true,
14
+ "modules": ["CLAUDE-intelligence.md"]
15
+ },
16
+ "test": { "agents": true, "patterns": true, "checklist": false, "modules": [] },
17
+ "work": {
18
+ "agents": true,
19
+ "patterns": true,
20
+ "checklist": true,
21
+ "modules": ["CLAUDE-intelligence.md", "CLAUDE-storage.md"]
22
+ },
23
+ "cleanup": { "agents": true, "patterns": true, "checklist": true, "modules": [] },
24
+ "spec": {
25
+ "agents": true,
26
+ "patterns": true,
27
+ "checklist": true,
28
+ "modules": ["CLAUDE-intelligence.md"]
29
+ },
30
+ "init": { "agents": false, "patterns": true, "checklist": false, "modules": [] },
31
+ "task": {
32
+ "agents": false,
33
+ "patterns": false,
34
+ "checklist": false,
35
+ "modules": ["CLAUDE-intelligence.md", "CLAUDE-storage.md"]
36
+ },
37
+ "ship": {
38
+ "agents": false,
39
+ "patterns": true,
40
+ "checklist": true,
41
+ "modules": ["CLAUDE-intelligence.md", "CLAUDE-storage.md"]
42
+ },
43
+ "done": {
44
+ "agents": false,
45
+ "patterns": false,
46
+ "checklist": true,
47
+ "modules": ["CLAUDE-storage.md"]
48
+ },
49
+ "review": { "agents": true, "patterns": true, "checklist": true, "modules": [] },
50
+ "refactor": {
51
+ "agents": true,
52
+ "patterns": true,
53
+ "checklist": true,
54
+ "modules": ["CLAUDE-intelligence.md"]
55
+ },
56
+ "sync": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
57
+ "dash": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
58
+ "next": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
59
+ "pause": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
60
+ "resume": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
61
+ "idea": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
62
+ "history": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
63
+ "status": { "agents": false, "patterns": false, "checklist": false, "modules": [] },
64
+ "*": { "agents": true, "patterns": true, "checklist": false, "modules": [] }
65
+ }
66
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Fibonacci Estimation Module
3
+ *
4
+ * Provides Fibonacci-based story point estimation with
5
+ * points-to-time conversion and historical suggestion.
6
+ */
7
+
8
+ import outcomeRecorder from '../outcomes/recorder'
9
+
10
+ // =============================================================================
11
+ // Constants
12
+ // =============================================================================
13
+
14
+ /** Valid Fibonacci story points */
15
+ export const FIBONACCI_POINTS = [1, 2, 3, 5, 8, 13, 21] as const
16
+ export type FibonacciPoint = (typeof FIBONACCI_POINTS)[number]
17
+
18
+ /** Default points-to-minutes mapping */
19
+ const DEFAULT_MINUTES_MAP: Record<FibonacciPoint, { min: number; max: number; typical: number }> = {
20
+ 1: { min: 5, max: 15, typical: 10 },
21
+ 2: { min: 15, max: 30, typical: 20 },
22
+ 3: { min: 30, max: 60, typical: 45 },
23
+ 5: { min: 60, max: 120, typical: 90 },
24
+ 8: { min: 120, max: 240, typical: 180 },
25
+ 13: { min: 240, max: 480, typical: 360 },
26
+ 21: { min: 480, max: 960, typical: 720 },
27
+ }
28
+
29
+ // =============================================================================
30
+ // Validation
31
+ // =============================================================================
32
+
33
+ /** Check if a number is a valid Fibonacci point */
34
+ export const isValidPoint = (n: number): n is FibonacciPoint =>
35
+ FIBONACCI_POINTS.includes(n as FibonacciPoint)
36
+
37
+ // =============================================================================
38
+ // Points-to-Time Conversion
39
+ // =============================================================================
40
+
41
+ /** Get the time range for a given point value */
42
+ export const pointsToMinutes = (
43
+ points: FibonacciPoint
44
+ ): { min: number; max: number; typical: number } => {
45
+ return DEFAULT_MINUTES_MAP[points]
46
+ }
47
+
48
+ /** Format a minute count as a human-readable duration */
49
+ export const formatMinutes = (minutes: number): string => {
50
+ if (minutes < 60) return `${minutes}m`
51
+ const hours = Math.floor(minutes / 60)
52
+ const mins = minutes % 60
53
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
54
+ }
55
+
56
+ /** Get a human-readable time range for a point value */
57
+ export const pointsToTimeRange = (points: FibonacciPoint): string => {
58
+ const range = pointsToMinutes(points)
59
+ return `${formatMinutes(range.min)}–${formatMinutes(range.max)}`
60
+ }
61
+
62
+ // =============================================================================
63
+ // Historical Suggestion
64
+ // =============================================================================
65
+
66
+ /**
67
+ * Suggest a point estimate based on historical outcomes for similar task types.
68
+ * Returns null if not enough data (< 3 outcomes).
69
+ */
70
+ export const suggestFromHistory = async (
71
+ projectId: string,
72
+ taskType: string
73
+ ): Promise<{ points: FibonacciPoint; basedOn: number } | null> => {
74
+ const outcomes = await outcomeRecorder.getAll(projectId)
75
+
76
+ // Filter by task type tag
77
+ const relevant = outcomes.filter((o) => o.tags?.includes(taskType))
78
+
79
+ if (relevant.length < 3) return null
80
+
81
+ // Calculate average actual duration in minutes
82
+ const totalMinutes = relevant.reduce((sum, o) => {
83
+ return sum + parseDuration(o.actualDuration)
84
+ }, 0)
85
+ const avgMinutes = totalMinutes / relevant.length
86
+
87
+ // Find closest Fibonacci point by typical time
88
+ const closest = findClosestPoint(avgMinutes)
89
+
90
+ return { points: closest, basedOn: relevant.length }
91
+ }
92
+
93
+ // =============================================================================
94
+ // Helpers
95
+ // =============================================================================
96
+
97
+ /** Find the Fibonacci point whose typical time is closest to the given minutes */
98
+ export const findClosestPoint = (minutes: number): FibonacciPoint => {
99
+ let closest: FibonacciPoint = 1
100
+ let smallestDiff = Number.POSITIVE_INFINITY
101
+
102
+ for (const point of FIBONACCI_POINTS) {
103
+ const diff = Math.abs(DEFAULT_MINUTES_MAP[point].typical - minutes)
104
+ if (diff < smallestDiff) {
105
+ smallestDiff = diff
106
+ closest = point
107
+ }
108
+ }
109
+
110
+ return closest
111
+ }
112
+
113
+ /** Parse a duration string like "2h 30m" to minutes */
114
+ const parseDuration = (duration: string): number => {
115
+ let minutes = 0
116
+
117
+ const hourMatch = duration.match(/(\d+)h/)
118
+ if (hourMatch) {
119
+ minutes += Number.parseInt(hourMatch[1], 10) * 60
120
+ }
121
+
122
+ const minMatch = duration.match(/(\d+)m/)
123
+ if (minMatch) {
124
+ minutes += Number.parseInt(minMatch[1], 10)
125
+ }
126
+
127
+ return minutes
128
+ }