feat(fleet): add machine-readable NORTH_STAR.yaml + Markdown projection (#656)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #656.
This commit is contained in:
2026-06-24 14:40:09 +00:00
parent cabb179d5a
commit 61b1bdac2a
4 changed files with 724 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
import { readFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import {
parseNorthStar,
renderNorthStarMarkdown,
resolveNorthStarPaths,
type NorthStar,
} from './fleet.js';
// Repo root resolved from this spec file: packages/mosaic/src/commands → up 4.
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
const yamlPath = join(repoRoot, 'docs', 'fleet', 'NORTH_STAR.yaml');
async function loadYamlText(): Promise<string> {
return readFile(yamlPath, 'utf8');
}
async function loadParsed(): Promise<NorthStar> {
return parseNorthStar(await loadYamlText());
}
describe('NORTH_STAR.yaml', () => {
it('parses to a typed object with the required top-level keys', async () => {
const ns = await loadParsed();
expect(ns.version).toBeTypeOf('number');
expect(ns.mission).toContain('self-driving Mosaic delivery fleet');
expect(ns.substrate.note).toBeTruthy();
expect(ns.standing_objectives.length).toBeGreaterThan(0);
expect(ns.success_criteria.length).toBeGreaterThan(0);
expect(ns.workstreams.length).toBeGreaterThan(0);
expect(ns.goals.length).toBeGreaterThan(0);
expect(ns.assumptions.length).toBeGreaterThan(0);
expect(ns.spend.advisory).toBe(true);
});
it('names the native Postgres storage layer and declares no Hermes runtime dependency', async () => {
const rawText = await loadYamlText();
const lower = rawText.toLowerCase();
expect(rawText).toContain('@mosaicstack/db');
expect(lower).toContain('postgres');
expect(lower).toContain('pglite');
// The doctrine explicitly disowns Hermes ("NOT Hermes"); the only mentions
// are negations. Assert there is no Hermes RUNTIME dependency: no hermes
// CLI/kanban invocation and no ~/.hermes storage reference.
expect(lower).not.toContain('hermes kanban');
expect(lower).not.toContain('~/.hermes');
expect(lower).not.toContain('hermes mcp');
});
it('declares all NS-1..NS-8 standing objectives', async () => {
const ns = await loadParsed();
const ids = ns.standing_objectives.map((o) => o.id);
for (let n = 1; n <= 8; n += 1) {
expect(ids).toContain(`NS-${n}`);
}
});
it('declares all AC-NS-1..AC-NS-5 success criteria', async () => {
const ns = await loadParsed();
const ids = ns.success_criteria.map((c) => c.id);
for (let n = 1; n <= 5; n += 1) {
expect(ids).toContain(`AC-NS-${n}`);
}
});
it('seeds the expected backlog goal ids', async () => {
const ns = await loadParsed();
const ids = ns.goals.map((g) => g.id);
expect(ids).toEqual(
expect.arrayContaining(['A1', 'A2', 'A3a', 'A3b', 'A4', 'B1', 'B2', 'B3a', 'B3b', 'G1']),
);
});
it('has a coherent depends_on DAG (every dependency references a known goal)', async () => {
const ns = await loadParsed();
const ids = new Set(ns.goals.map((g) => g.id));
for (const goal of ns.goals) {
for (const dep of goal.depends_on) {
expect(ids.has(dep)).toBe(true);
}
// No goal may depend on itself.
expect(goal.depends_on).not.toContain(goal.id);
}
// A1 is the root: no dependencies.
const a1 = ns.goals.find((g) => g.id === 'A1');
expect(a1?.depends_on).toEqual([]);
});
it('marks spend as advisory with a degrade-to-TTL note', async () => {
const ns = await loadParsed();
expect(ns.spend.advisory).toBe(true);
expect(ns.spend.note.toLowerCase()).toContain('ttl');
});
});
describe('renderNorthStarMarkdown', () => {
it('is a pure deterministic projection (round-trip stable)', async () => {
const ns = await loadParsed();
const first = renderNorthStarMarkdown(ns);
const second = renderNorthStarMarkdown(ns);
expect(first).toBe(second);
// Re-parsing the same YAML and re-rendering yields identical bytes.
const reparsed = parseNorthStar(await loadYamlText());
expect(renderNorthStarMarkdown(reparsed)).toBe(first);
});
it('matches the committed NORTH_STAR.md projection (regenerate if this fails)', async () => {
const ns = await loadParsed();
const rendered = `${renderNorthStarMarkdown(ns)}\n`;
const committed = await readFile(join(repoRoot, 'docs', 'fleet', 'NORTH_STAR.md'), 'utf8');
expect(rendered).toBe(committed);
});
it('projects mission, objectives, criteria, goals, assumptions, and spend', async () => {
const ns = await loadParsed();
const md = renderNorthStarMarkdown(ns);
expect(md).toContain('# Mosaic Fleet — NORTH STAR');
expect(md).toContain('## Mission');
expect(md).toContain('## Standing objectives');
expect(md).toContain('**NS-1**');
expect(md).toContain('**AC-NS-5**');
expect(md).toContain('## Goals (backlog projection)');
// Tables are column-padded (prettier-style); match the row id, not exact spacing.
expect(md).toMatch(/\| A1\s+\|/);
expect(md).toContain('## Assumptions (vetoable)');
expect(md).toContain('**advisory:** true');
// The banner disowns Hermes; the projection carries no Hermes runtime hook.
expect(md.toLowerCase()).not.toContain('hermes kanban');
expect(md.toLowerCase()).not.toContain('~/.hermes');
});
it('does no network or CLI work (pure functions; only the writer touches IO)', () => {
// parseNorthStar + renderNorthStarMarkdown take strings and return strings.
// Guard against accidental IO by asserting fetch/spawn are never invoked.
const fetchSpy = vi.spyOn(globalThis, 'fetch' as never).mockImplementation((() => {
throw new Error('network access is forbidden in the NORTH_STAR generator');
}) as never);
try {
const yaml = [
'version: 1',
'mission: m',
'substrate:',
' note: n',
'standing_objectives:',
' - { id: NS-1, text: t }',
'success_criteria:',
' - { id: AC-NS-1, text: t }',
'workstreams:',
' - { id: A, title: t }',
'goals:',
' - { id: A1, title: t, phase: 1, priority: must-have, depends_on: [] }',
'assumptions:',
' - { id: ASM-1, vetoable: true, text: t }',
'spend:',
' advisory: true',
' note: TTL',
'',
].join('\n');
const ns = parseNorthStar(yaml);
const md = renderNorthStarMarkdown(ns);
expect(md).toContain('# Mosaic Fleet — NORTH STAR');
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
});
describe('parseNorthStar validation', () => {
it('throws on a missing required key', () => {
expect(() => parseNorthStar('version: 1\n')).toThrow();
});
it('throws when spend.advisory is not a boolean', () => {
const yaml = [
'version: 1',
'mission: m',
'substrate: { note: n }',
'standing_objectives: [{ id: NS-1, text: t }]',
'success_criteria: [{ id: AC-NS-1, text: t }]',
'workstreams: [{ id: A, title: t }]',
'goals: [{ id: A1, title: t, phase: 1, priority: must-have, depends_on: [] }]',
'assumptions: [{ id: ASM-1, vetoable: true, text: t }]',
'spend: { advisory: maybe, note: TTL }',
'',
].join('\n');
expect(() => parseNorthStar(yaml)).toThrow(/spend\.advisory/);
});
});
describe('resolveNorthStarPaths', () => {
it('resolves YAML + Markdown under docs/fleet from a given repo root', () => {
const paths = resolveNorthStarPaths('/repo');
expect(paths.yamlPath).toBe('/repo/docs/fleet/NORTH_STAR.yaml');
expect(paths.markdownPath).toBe('/repo/docs/fleet/NORTH_STAR.md');
});
});

View File

@@ -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 [