Files
stack/v1/packages/forge/src/brief-classifier.ts
T

103 lines
2.9 KiB
TypeScript

import { STAGE_SEQUENCE, STRATEGIC_KEYWORDS, TECHNICAL_KEYWORDS } from './constants.js';
import type { BriefClass, ClassSource } from './types.js';
const VALID_CLASSES: ReadonlySet<string> = new Set<BriefClass>([
'strategic',
'technical',
'hotfix',
]);
/**
* Auto-classify a brief based on keyword analysis.
* Returns 'strategic' if strategic keywords dominate,
* 'technical' if any technical keywords are found,
* otherwise defaults to 'strategic' (full pipeline).
*/
export function classifyBrief(text: string): BriefClass {
const lower = text.toLowerCase();
let strategicHits = 0;
let technicalHits = 0;
for (const kw of STRATEGIC_KEYWORDS) {
if (lower.includes(kw)) strategicHits++;
}
for (const kw of TECHNICAL_KEYWORDS) {
if (lower.includes(kw)) technicalHits++;
}
if (strategicHits > technicalHits) return 'strategic';
if (technicalHits > 0) return 'technical';
return 'strategic';
}
/**
* Parse YAML frontmatter from a brief.
* Supports simple `key: value` pairs via regex (no YAML dependency).
*/
export function parseBriefFrontmatter(text: string): Record<string, string> {
const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
if (!match?.[1]) return {};
const result: Record<string, string> = {};
for (const line of match[1].split('\n')) {
const km = line.trim().match(/^(\w[\w-]*)\s*:\s*(.+)$/);
if (km?.[1] && km[2]) {
result[km[1]] = km[2].trim().replace(/^["']|["']$/g, '');
}
}
return result;
}
/**
* Determine brief class from all sources with priority:
* CLI flag > frontmatter > auto-classify.
*/
export function determineBriefClass(
text: string,
cliClass?: string,
): { briefClass: BriefClass; classSource: ClassSource } {
if (cliClass && VALID_CLASSES.has(cliClass)) {
return { briefClass: cliClass as BriefClass, classSource: 'cli' };
}
const fm = parseBriefFrontmatter(text);
if (fm['class'] && VALID_CLASSES.has(fm['class'])) {
return { briefClass: fm['class'] as BriefClass, classSource: 'frontmatter' };
}
return { briefClass: classifyBrief(text), classSource: 'auto' };
}
/**
* Build the stage list based on brief classification.
* - strategic: full pipeline (all stages)
* - technical: skip board (01-board)
* - hotfix: skip board + brief analyzer
*
* forceBoard re-adds the board stage regardless of class.
*/
export function stagesForClass(briefClass: BriefClass, forceBoard = false): string[] {
const stages = ['00-intake', '00b-discovery'];
if (briefClass === 'strategic' || forceBoard) {
stages.push('01-board');
}
if (briefClass === 'strategic' || briefClass === 'technical' || forceBoard) {
stages.push('01b-brief-analyzer');
}
stages.push(
'02-planning-1',
'03-planning-2',
'04-planning-3',
'05-coding',
'06-review',
'07-remediate',
'08-test',
'09-deploy',
);
// Maintain canonical order
return stages.filter((s) => STAGE_SEQUENCE.includes(s));
}