215 lines
5.9 KiB
JavaScript
215 lines
5.9 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const PHASES = [
|
|
'DISCOVERY',
|
|
'BRAINSTORM',
|
|
'PRUNE',
|
|
'DISPATCH',
|
|
'BUILD',
|
|
'REVIEW',
|
|
'VALIDATE',
|
|
'REFACTOR',
|
|
'DOC',
|
|
'COMPLETED',
|
|
'ABORTED',
|
|
];
|
|
|
|
// Backward-allowed transitions: REFACTOR loops back to BUILD by design.
|
|
const BACKWARD_ALLOWED = new Set([
|
|
'REFACTOR->BUILD',
|
|
]);
|
|
|
|
function resolveRoot(projectRoot) {
|
|
return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
}
|
|
|
|
function statePath(projectRoot) {
|
|
return path.join(resolveRoot(projectRoot), '_byan-output', 'fd-state.json');
|
|
}
|
|
|
|
function ensureDir(filePath) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
}
|
|
|
|
function readState(projectRoot) {
|
|
const p = statePath(projectRoot);
|
|
if (!fs.existsSync(p)) return null;
|
|
try {
|
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeState(state, projectRoot) {
|
|
const p = statePath(projectRoot);
|
|
ensureDir(p);
|
|
fs.writeFileSync(p, JSON.stringify(state, null, 2));
|
|
return p;
|
|
}
|
|
|
|
function slugify(s) {
|
|
return String(s || 'feature')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '')
|
|
.slice(0, 40);
|
|
}
|
|
|
|
function stampId(now = new Date(), slug) {
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
const s =
|
|
now.getFullYear().toString() +
|
|
pad(now.getMonth() + 1) +
|
|
pad(now.getDate()) +
|
|
'-' +
|
|
pad(now.getHours()) +
|
|
pad(now.getMinutes()) +
|
|
pad(now.getSeconds());
|
|
return `${s}-${slugify(slug)}`;
|
|
}
|
|
|
|
export function start({ featureName, projectRoot, now = new Date(), force = false } = {}) {
|
|
const existing = readState(projectRoot);
|
|
if (existing && !['COMPLETED', 'ABORTED'].includes(existing.phase) && !force) {
|
|
throw new Error(
|
|
`FD already in progress (phase ${existing.phase}, fd_id ${existing.fd_id}). Abort or complete it first, or pass force=true.`
|
|
);
|
|
}
|
|
|
|
const state = {
|
|
fd_id: stampId(now, featureName),
|
|
feature_name: featureName || 'unnamed',
|
|
phase: 'DISCOVERY',
|
|
started_at: now.toISOString(),
|
|
updated_at: now.toISOString(),
|
|
phase_history: [{ phase: 'DISCOVERY', entered_at: now.toISOString() }],
|
|
project_context: null,
|
|
raw_ideas: [],
|
|
backlog: [],
|
|
dispatch_table: [],
|
|
commits: [],
|
|
review_findings: [],
|
|
validate_verdict: null,
|
|
refactor_log: [],
|
|
doc_log: [],
|
|
notes: [],
|
|
};
|
|
writeState(state, projectRoot);
|
|
return state;
|
|
}
|
|
|
|
export function status({ projectRoot } = {}) {
|
|
const state = readState(projectRoot);
|
|
if (!state) {
|
|
return { active: false, phase: null, fd_id: null };
|
|
}
|
|
return {
|
|
active: !['COMPLETED', 'ABORTED'].includes(state.phase),
|
|
...state,
|
|
};
|
|
}
|
|
|
|
const BRAINSTORM_MIN_IDEAS = 10;
|
|
|
|
export function advance({ to, note, projectRoot, now = new Date(), force = false } = {}) {
|
|
if (!PHASES.includes(to)) {
|
|
throw new Error(`Invalid target phase ${to}. Must be one of ${PHASES.join(', ')}`);
|
|
}
|
|
const state = readState(projectRoot);
|
|
if (!state) throw new Error('No active FD session. Call start() first.');
|
|
if (['COMPLETED', 'ABORTED'].includes(state.phase)) {
|
|
throw new Error(`Current FD session is ${state.phase} and cannot advance.`);
|
|
}
|
|
|
|
const order = PHASES.indexOf(state.phase);
|
|
const target = PHASES.indexOf(to);
|
|
const transitionKey = `${state.phase}->${to}`;
|
|
const isBackward = target < order;
|
|
const isTerminal = ['ABORTED', 'COMPLETED'].includes(to);
|
|
|
|
if (isBackward && !isTerminal && !BACKWARD_ALLOWED.has(transitionKey)) {
|
|
throw new Error(
|
|
`Cannot move backwards from ${state.phase} to ${to}. Allowed loop: REFACTOR->BUILD. Use abort() or fix the workflow.`
|
|
);
|
|
}
|
|
|
|
// DISCOVERY exit gate : need project_context populated (unless forced)
|
|
if (
|
|
state.phase === 'DISCOVERY' &&
|
|
to !== 'DISCOVERY' &&
|
|
!isTerminal &&
|
|
!force
|
|
) {
|
|
if (!state.project_context) {
|
|
throw new Error(
|
|
`DISCOVERY requires project_context to be set before advancing. Populate via update({ patch: { project_context: { name, summary, source } } }), or pass force=true to skip.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// BRAINSTORM exit gate : need >= BRAINSTORM_MIN_IDEAS raw ideas
|
|
if (
|
|
state.phase === 'BRAINSTORM' &&
|
|
to !== 'BRAINSTORM' &&
|
|
!isTerminal &&
|
|
!force
|
|
) {
|
|
const n = Array.isArray(state.raw_ideas) ? state.raw_ideas.length : 0;
|
|
if (n < BRAINSTORM_MIN_IDEAS) {
|
|
throw new Error(
|
|
`BRAINSTORM requires at least ${BRAINSTORM_MIN_IDEAS} raw ideas before advancing (currently ${n}). Add more via update({ patch: { raw_ideas: [...] } }), or pass force=true to skip.`
|
|
);
|
|
}
|
|
}
|
|
|
|
state.phase = to;
|
|
state.updated_at = now.toISOString();
|
|
state.phase_history.push({ phase: to, entered_at: now.toISOString(), note: note || null });
|
|
|
|
writeState(state, projectRoot);
|
|
return state;
|
|
}
|
|
|
|
export function update({ patch = {}, projectRoot, now = new Date() } = {}) {
|
|
const state = readState(projectRoot);
|
|
if (!state) throw new Error('No active FD session.');
|
|
|
|
const allowed = [
|
|
'project_context',
|
|
'raw_ideas',
|
|
'backlog',
|
|
'dispatch_table',
|
|
'commits',
|
|
'review_findings',
|
|
'validate_verdict',
|
|
'refactor_log',
|
|
'doc_log',
|
|
'notes',
|
|
'feature_name',
|
|
];
|
|
for (const key of Object.keys(patch)) {
|
|
if (!allowed.includes(key)) {
|
|
throw new Error(`Field "${key}" is not patchable. Allowed: ${allowed.join(', ')}`);
|
|
}
|
|
state[key] = patch[key];
|
|
}
|
|
state.updated_at = now.toISOString();
|
|
|
|
writeState(state, projectRoot);
|
|
return state;
|
|
}
|
|
|
|
export function abort({ reason, projectRoot, now = new Date() } = {}) {
|
|
const state = readState(projectRoot);
|
|
if (!state) throw new Error('No FD session to abort.');
|
|
state.phase = 'ABORTED';
|
|
state.updated_at = now.toISOString();
|
|
state.phase_history.push({ phase: 'ABORTED', entered_at: now.toISOString(), note: reason || null });
|
|
writeState(state, projectRoot);
|
|
return state;
|
|
}
|
|
|
|
export const ALL_PHASES = PHASES;
|
|
export const BRAINSTORM_MIN = BRAINSTORM_MIN_IDEAS;
|