File size: 8,826 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
/**
 * Spec Parser - Pure functions for parsing spec content and detecting markers
 *
 * Extracts tasks from generated specs, detects progress markers,
 * and extracts summary content from various formats.
 */

import type { ParsedTask } from '@automaker/types';

/**
 * Parse a single task line
 * Format: - [ ] T###: Description | File: path/to/file
 */
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
  // Match pattern: - [ ] T###: Description | File: path
  const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
  if (!taskMatch) {
    // Try simpler pattern without file
    const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
    if (simpleMatch) {
      return {
        id: simpleMatch[1],
        description: simpleMatch[2].trim(),
        phase: currentPhase,
        status: 'pending',
      };
    }
    return null;
  }

  return {
    id: taskMatch[1],
    description: taskMatch[2].trim(),
    filePath: taskMatch[3]?.trim(),
    phase: currentPhase,
    status: 'pending',
  };
}

/**
 * Parse tasks from generated spec content
 * Looks for the ```tasks code block and extracts task lines
 * Format: - [ ] T###: Description | File: path/to/file
 */
export function parseTasksFromSpec(specContent: string): ParsedTask[] {
  const tasks: ParsedTask[] = [];

  // Extract content within ```tasks ... ``` block
  const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
  if (!tasksBlockMatch) {
    // Try fallback: look for task lines anywhere in content
    const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
    if (!taskLines) {
      return tasks;
    }
    // Parse fallback task lines
    let currentPhase: string | undefined;
    for (const line of taskLines) {
      const parsed = parseTaskLine(line, currentPhase);
      if (parsed) {
        tasks.push(parsed);
      }
    }
    return tasks;
  }

  const tasksContent = tasksBlockMatch[1];
  const lines = tasksContent.split('\n');

  let currentPhase: string | undefined;

  for (const line of lines) {
    const trimmedLine = line.trim();

    // Check for phase header (e.g., "## Phase 1: Foundation")
    const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
    if (phaseMatch) {
      currentPhase = phaseMatch[1].trim();
      continue;
    }

    // Check for task line
    if (trimmedLine.startsWith('- [ ]')) {
      const parsed = parseTaskLine(trimmedLine, currentPhase);
      if (parsed) {
        tasks.push(parsed);
      }
    }
  }

  return tasks;
}

/**
 * Detect [TASK_START] marker in text and extract task ID
 * Format: [TASK_START] T###: Description
 */
export function detectTaskStartMarker(text: string): string | null {
  const match = text.match(/\[TASK_START\]\s*(T\d{3})/);
  return match ? match[1] : null;
}

/**
 * Detect [TASK_COMPLETE] marker in text and extract task ID and summary
 * Format: [TASK_COMPLETE] T###: Brief summary
 */
export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null {
  // Use a regex that captures the summary until newline or next task marker
  // Allow brackets in summary content (e.g., "supports array[index] access")
  // Pattern breakdown:
  // - \[TASK_COMPLETE\]\s* - Match the marker
  // - (T\d{3}) - Capture task ID
  // - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket)
  // - But we want to allow brackets in summary, so we use a different approach:
  // - Match summary until newline, then trim any trailing markers in post-processing
  const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i);
  if (!match) return null;

  // Post-process: remove trailing task markers from summary if present
  let summary = match[2]?.trim();
  if (summary) {
    // Remove trailing content that looks like another marker
    summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim();
  }

  return {
    id: match[1],
    summary: summary || undefined,
  };
}

/**
 * Detect [PHASE_COMPLETE] marker in text and extract phase number
 * Format: [PHASE_COMPLETE] Phase N complete
 */
export function detectPhaseCompleteMarker(text: string): number | null {
  const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i);
  return match ? parseInt(match[1], 10) : null;
}

/**
 * Fallback spec detection when [SPEC_GENERATED] marker is missing
 * Looks for structural elements that indicate a spec was generated.
 * This is especially important for non-Claude models that may not output
 * the explicit [SPEC_GENERATED] marker.
 *
 * @param text - The text content to check for spec structure
 * @returns true if the text appears to be a generated spec
 */
export function detectSpecFallback(text: string): boolean {
  // Check for key structural elements of a spec
  const hasTasksBlock = /```tasks[\s\S]*```/.test(text);
  const hasTaskLines = /- \[ \] T\d{3}:/.test(text);

  // Check for common spec sections (case-insensitive)
  const hasAcceptanceCriteria = /acceptance criteria/i.test(text);
  const hasTechnicalContext = /technical context/i.test(text);
  const hasProblemStatement = /problem statement/i.test(text);
  const hasUserStory = /user story/i.test(text);
  // Additional patterns for different model outputs
  const hasGoal = /\*\*Goal\*\*:/i.test(text);
  const hasSolution = /\*\*Solution\*\*:/i.test(text);
  const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text);
  const hasOverview = /##\s*(overview|summary)/i.test(text);

  // Spec is detected if we have task structure AND at least some spec content
  const hasTaskStructure = hasTasksBlock || hasTaskLines;
  const hasSpecContent =
    hasAcceptanceCriteria ||
    hasTechnicalContext ||
    hasProblemStatement ||
    hasUserStory ||
    hasGoal ||
    hasSolution ||
    hasImplementation ||
    hasOverview;

  return hasTaskStructure && hasSpecContent;
}

/**
 * Extract summary from text content
 * Checks for multiple formats in order of priority:
 * 1. Explicit <summary> tags
 * 2. ## Summary section (markdown)
 * 3. **Goal**: section (lite planning mode)
 * 4. **Problem**: or **Problem Statement**: section (spec/full modes)
 * 5. **Solution**: section as fallback
 *
 * Note: Uses last match for each pattern to avoid stale summaries
 * when agent output accumulates across multiple runs.
 *
 * @param text - The text content to extract summary from
 * @returns The extracted summary string, or null if no summary found
 */
export function extractSummary(text: string): string | null {
  // Helper to truncate content to first paragraph with max length
  const truncate = (content: string, maxLength: number): string => {
    const firstPara = content.split(/\n\n/)[0];
    return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara;
  };

  // Helper to get last match from matchAll results
  const getLastMatch = (matches: IterableIterator<RegExpMatchArray>): RegExpMatchArray | null => {
    const arr = [...matches];
    return arr.length > 0 ? arr[arr.length - 1] : null;
  };

  // Check for explicit <summary> tags first (use last match to avoid stale summaries)
  const summaryMatches = text.matchAll(/<summary>([\s\S]*?)<\/summary>/g);
  const summaryMatch = getLastMatch(summaryMatches);
  if (summaryMatch) {
    return summaryMatch[1].trim();
  }

  // Check for ## Summary section (use last match)
  // Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections
  // (like "### Root Cause", "### Fix Applied") that belong to the summary content.
  const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi);
  const sectionMatch = getLastMatch(sectionMatches);
  if (sectionMatch) {
    const content = sectionMatch[1].trim();
    // Keep full content (including ### subsections) up to max length
    return content.length > 500 ? `${content.substring(0, 500)}...` : content;
  }

  // Check for **Goal**: section (lite mode, use last match)
  const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi);
  const goalMatch = getLastMatch(goalMatches);
  if (goalMatch) {
    return goalMatch[1].trim();
  }

  // Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match)
  const problemMatches = text.matchAll(
    /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi
  );
  const problemMatch = getLastMatch(problemMatches);
  if (problemMatch) {
    return truncate(problemMatch[1].trim(), 500);
  }

  // Check for **Solution**: section as fallback (use last match)
  const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi);
  const solutionMatch = getLastMatch(solutionMatches);
  if (solutionMatch) {
    return truncate(solutionMatch[1].trim(), 300);
  }

  return null;
}