site-mariage/_byan/mcp/byan-mcp-server/lib/fd-state.js
Corentin Joguet bff653acd6 first commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:30:37 +02:00

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;