feat(fleet): add machine-readable NORTH_STAR.yaml + Markdown projection (#656)
This commit was merged in pull request #656.
This commit is contained in:
@@ -197,6 +197,292 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
return agent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
|
||||
//
|
||||
// docs/fleet/NORTH_STAR.yaml is the single source of truth. The Markdown file
|
||||
// (docs/fleet/NORTH_STAR.md) is a deterministic, pure projection of the YAML —
|
||||
// no network, no CLI, no clock. Edit the YAML, regenerate the .md.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NorthStarIdText {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface NorthStarWorkstream {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface NorthStarGoal {
|
||||
id: string;
|
||||
title: string;
|
||||
phase: number;
|
||||
priority: string;
|
||||
depends_on: string[];
|
||||
}
|
||||
|
||||
export interface NorthStarAssumption {
|
||||
id: string;
|
||||
vetoable: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface NorthStarSpend {
|
||||
advisory: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface NorthStar {
|
||||
version: number;
|
||||
mission: string;
|
||||
substrate: { note: string };
|
||||
standing_objectives: NorthStarIdText[];
|
||||
success_criteria: NorthStarIdText[];
|
||||
workstreams: NorthStarWorkstream[];
|
||||
goals: NorthStarGoal[];
|
||||
assumptions: NorthStarAssumption[];
|
||||
spend: NorthStarSpend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate the NORTH_STAR YAML text into a typed NorthStar object.
|
||||
* Pure: no IO, no network. Throws a descriptive error when a required key is
|
||||
* missing or malformed so the generator/tests fail loudly rather than emit a
|
||||
* partial projection.
|
||||
*/
|
||||
export function parseNorthStar(rawText: string): NorthStar {
|
||||
const parsed = YAML.parse(rawText) as Record<string, unknown> | null;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('NORTH_STAR.yaml did not parse to a mapping.');
|
||||
}
|
||||
|
||||
const requireString = (value: unknown, key: string): string => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`NORTH_STAR.yaml: "${key}" must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const requireArray = (value: unknown, key: string): unknown[] => {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error(`NORTH_STAR.yaml: "${key}" must be a non-empty array.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const idText = (value: unknown, key: string, index: number): NorthStarIdText => {
|
||||
const row = value as Record<string, unknown>;
|
||||
return {
|
||||
id: requireString(row?.id, `${key}[${index}].id`),
|
||||
text: requireString(row?.text, `${key}[${index}].text`),
|
||||
};
|
||||
};
|
||||
|
||||
const version = parsed.version;
|
||||
if (typeof version !== 'number') {
|
||||
throw new Error('NORTH_STAR.yaml: "version" must be a number.');
|
||||
}
|
||||
|
||||
const substrate = parsed.substrate as Record<string, unknown> | undefined;
|
||||
|
||||
const spendRaw = parsed.spend as Record<string, unknown> | undefined;
|
||||
if (!spendRaw || typeof spendRaw.advisory !== 'boolean') {
|
||||
throw new Error('NORTH_STAR.yaml: "spend.advisory" must be a boolean.');
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
mission: requireString(parsed.mission, 'mission'),
|
||||
substrate: { note: requireString(substrate?.note, 'substrate.note') },
|
||||
standing_objectives: requireArray(parsed.standing_objectives, 'standing_objectives').map(
|
||||
(row, i) => idText(row, 'standing_objectives', i),
|
||||
),
|
||||
success_criteria: requireArray(parsed.success_criteria, 'success_criteria').map((row, i) =>
|
||||
idText(row, 'success_criteria', i),
|
||||
),
|
||||
workstreams: requireArray(parsed.workstreams, 'workstreams').map((row, i) => {
|
||||
const ws = row as Record<string, unknown>;
|
||||
return {
|
||||
id: requireString(ws?.id, `workstreams[${i}].id`),
|
||||
title: requireString(ws?.title, `workstreams[${i}].title`),
|
||||
};
|
||||
}),
|
||||
goals: requireArray(parsed.goals, 'goals').map((row, i) => {
|
||||
const goal = row as Record<string, unknown>;
|
||||
const dependsRaw = goal?.depends_on ?? [];
|
||||
if (!Array.isArray(dependsRaw)) {
|
||||
throw new Error(`NORTH_STAR.yaml: goals[${i}].depends_on must be an array.`);
|
||||
}
|
||||
const phase = goal?.phase;
|
||||
if (typeof phase !== 'number') {
|
||||
throw new Error(`NORTH_STAR.yaml: goals[${i}].phase must be a number.`);
|
||||
}
|
||||
return {
|
||||
id: requireString(goal?.id, `goals[${i}].id`),
|
||||
title: requireString(goal?.title, `goals[${i}].title`),
|
||||
phase,
|
||||
priority: requireString(goal?.priority, `goals[${i}].priority`),
|
||||
depends_on: dependsRaw.map((dep, j) => requireString(dep, `goals[${i}].depends_on[${j}]`)),
|
||||
};
|
||||
}),
|
||||
assumptions: requireArray(parsed.assumptions, 'assumptions').map((row, i) => {
|
||||
const asm = row as Record<string, unknown>;
|
||||
if (typeof asm?.vetoable !== 'boolean') {
|
||||
throw new Error(`NORTH_STAR.yaml: assumptions[${i}].vetoable must be a boolean.`);
|
||||
}
|
||||
return {
|
||||
id: requireString(asm?.id, `assumptions[${i}].id`),
|
||||
vetoable: asm.vetoable,
|
||||
text: requireString(asm?.text, `assumptions[${i}].text`),
|
||||
};
|
||||
}),
|
||||
spend: {
|
||||
advisory: spendRaw.advisory,
|
||||
note: requireString(spendRaw?.note, 'spend.note'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a GitHub-Flavored-Markdown table with prettier-compatible column
|
||||
* alignment: each column is padded to the widest cell (minimum 3 for the
|
||||
* `---` divider) so the generated bytes survive `prettier --check` unchanged.
|
||||
* Pure; the row strings use the same single-code-unit dash/arrow glyphs that
|
||||
* prettier's string-width counts as width 1.
|
||||
*/
|
||||
function renderMarkdownTable(headers: string[], rows: string[][]): string[] {
|
||||
const widths = headers.map((header, col) =>
|
||||
Math.max(3, header.length, ...rows.map((row) => row[col]?.length ?? 0)),
|
||||
);
|
||||
const pad = (cell: string, col: number): string => cell.padEnd(widths[col] ?? 0, ' ');
|
||||
const formatRow = (cells: string[]): string =>
|
||||
`| ${cells.map((cell, col) => pad(cell, col)).join(' | ')} |`;
|
||||
const divider = `| ${widths.map((w) => '-'.repeat(w)).join(' | ')} |`;
|
||||
return [formatRow(headers), divider, ...rows.map(formatRow)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically project a parsed NorthStar into the Markdown doctrine doc.
|
||||
* Pure function of its input — same input always yields byte-identical output,
|
||||
* so the round-trip (YAML → render → write) is stable across runs. No clock, no
|
||||
* network, no CLI. Layout follows the repo's existing doctrine-doc convention
|
||||
* (heading, blockquote banner, then sections + tables, e.g. north-star.md /
|
||||
* mission-control/BOARD.md).
|
||||
*/
|
||||
export function renderNorthStarMarkdown(ns: NorthStar): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# Mosaic Fleet — NORTH STAR');
|
||||
lines.push('');
|
||||
lines.push('> **Generated file — do not edit by hand.**');
|
||||
lines.push(
|
||||
'> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure',
|
||||
);
|
||||
lines.push('> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).');
|
||||
lines.push('> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.');
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Mission');
|
||||
lines.push('');
|
||||
lines.push(ns.mission);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Substrate');
|
||||
lines.push('');
|
||||
lines.push(ns.substrate.note);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Standing objectives');
|
||||
lines.push('');
|
||||
for (const obj of ns.standing_objectives) {
|
||||
lines.push(`- **${obj.id}** — ${obj.text}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Success criteria');
|
||||
lines.push('');
|
||||
for (const ac of ns.success_criteria) {
|
||||
lines.push(`- **${ac.id}** — ${ac.text}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Workstreams');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
...renderMarkdownTable(
|
||||
['id', 'title'],
|
||||
ns.workstreams.map((ws) => [ws.id, ws.title]),
|
||||
),
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Goals (backlog projection)');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
...renderMarkdownTable(
|
||||
['id', 'title', 'phase', 'priority', 'depends_on'],
|
||||
ns.goals.map((goal) => [
|
||||
goal.id,
|
||||
goal.title,
|
||||
String(goal.phase),
|
||||
goal.priority,
|
||||
goal.depends_on.length > 0 ? goal.depends_on.join(', ') : '—',
|
||||
]),
|
||||
),
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Assumptions (vetoable)');
|
||||
lines.push('');
|
||||
for (const asm of ns.assumptions) {
|
||||
const veto = asm.vetoable ? 'vetoable' : 'fixed';
|
||||
lines.push(`- **${asm.id}** (${veto}) — ${asm.text}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Spend');
|
||||
lines.push('');
|
||||
lines.push(`- **advisory:** ${ns.spend.advisory ? 'true' : 'false'}`);
|
||||
lines.push(`- ${ns.spend.note}`);
|
||||
|
||||
// No trailing blank line: the writer appends a single newline, yielding the
|
||||
// one-newline EOF prettier expects (round-trip stays format:check-clean).
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the repo's docs/fleet directory from this compiled module's location.
|
||||
* fleet.ts lives at packages/mosaic/src/commands; docs/fleet sits at the repo
|
||||
* root. Exposed so the generator + tests share one path resolution.
|
||||
*/
|
||||
export function resolveNorthStarPaths(repoRoot?: string): {
|
||||
yamlPath: string;
|
||||
markdownPath: string;
|
||||
} {
|
||||
const root = repoRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
||||
const dir = join(root, 'docs', 'fleet');
|
||||
return {
|
||||
yamlPath: join(dir, 'NORTH_STAR.yaml'),
|
||||
markdownPath: join(dir, 'NORTH_STAR.md'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read NORTH_STAR.yaml, project it to Markdown, and write NORTH_STAR.md.
|
||||
* The only IO in the NORTH_STAR pipeline; the parse + render steps it composes
|
||||
* are pure. Returns the rendered Markdown so callers/tests can assert on it.
|
||||
*/
|
||||
export async function generateNorthStarMarkdown(repoRoot?: string): Promise<string> {
|
||||
const { yamlPath, markdownPath } = resolveNorthStarPaths(repoRoot);
|
||||
const rawText = await readFile(yamlPath, 'utf8');
|
||||
const ns = parseNorthStar(rawText);
|
||||
const markdown = renderNorthStarMarkdown(ns);
|
||||
await writeFile(markdownPath, `${markdown}\n`, 'utf8');
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user