From cd80ca10250b1b783b2a1948279f4f292432b0f5 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 24 Jun 2026 09:19:17 -0500 Subject: [PATCH] feat(fleet): add machine-readable NORTH_STAR.yaml + Markdown projection Add docs/fleet/NORTH_STAR.yaml as the single machine-readable source of truth for fleet planning, plus a pure, deterministic generator in fleet.ts that projects it to docs/fleet/NORTH_STAR.md. The Markdown is a projection (regenerate, do not hand-edit). Self-contained Mosaic: the substrate names the native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config) as the backlog of record. No Hermes runtime dependency. - NORTH_STAR.yaml: mission, substrate, NS-1..NS-8 standing objectives, AC-NS-1..AC-NS-5 success criteria, workstreams A..F, seeded goal backlog (A1,A2,A3a,A3b,A4,B1,B2,B3a,B3b,G1) with depends_on DAG, vetoable assumptions, advisory spend block. - parseNorthStar/renderNorthStarMarkdown/generateNorthStarMarkdown: pure parse + projection (no network/CLI/clock); writer is the only IO. Tables column-padded to stay prettier-clean and round-trip stable. - fleet-north-star.spec.ts: YAML validates + reuses ids, NS-*/AC-NS-* present, depends_on DAG coherent, projection deterministic and matches the committed .md, no network/CLI. Co-Authored-By: Claude Opus 4.8 --- docs/fleet/NORTH_STAR.md | 70 +++++ docs/fleet/NORTH_STAR.yaml | 169 +++++++++++ .../src/commands/fleet-north-star.spec.ts | 199 ++++++++++++ packages/mosaic/src/commands/fleet.ts | 286 ++++++++++++++++++ 4 files changed, 724 insertions(+) create mode 100644 docs/fleet/NORTH_STAR.md create mode 100644 docs/fleet/NORTH_STAR.yaml create mode 100644 packages/mosaic/src/commands/fleet-north-star.spec.ts diff --git a/docs/fleet/NORTH_STAR.md b/docs/fleet/NORTH_STAR.md new file mode 100644 index 0000000..3c6b37f --- /dev/null +++ b/docs/fleet/NORTH_STAR.md @@ -0,0 +1,70 @@ +# Mosaic Fleet — NORTH STAR + +> **Generated file — do not edit by hand.** +> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure +> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`). +> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency. + +## Mission + +A self-driving Mosaic delivery fleet that 24/7 unattended converts a machine-readable goal set into merged, CI-green, budget-bounded change — looping plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native backlog/dispatch engine. + +## Substrate + +The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config). NOT Hermes. + +## Standing objectives + +- **NS-1** — Single machine-readable source (this file) drives planning; prose docs are projections. +- **NS-2** — Every backlog item is an independently-shippable unit with stable id, priority, depends_on DAG, represented as a Mosaic Backlog card; spend tracked as advisory projection. +- **NS-3** — The supervisor guarantees movement: no idle agent while ready dependency-satisfied work exists; no empty backlog without a replan request; assignment via Mosaic native dispatch/claim. +- **NS-4** — Exactly one merge-gate approver; nothing reaches main except via pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the backstop. +- **NS-5** — Every unit bounded by wall-clock TTL on its claim; token caps enforced only where a real meter exists, else advisory. +- **NS-6** — Context cleared between tasks for ephemeral runners (reset_between_tasks); persona+mission re-injected per task. +- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs. +- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge. + +## Success criteria + +- **AC-NS-1** — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot. +- **AC-NS-2** — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop. +- **AC-NS-3** — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh. +- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists. +- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick. + +## Workstreams + +| id | title | +| --- | ---------------------------------------------------------------- | +| A | Substrate — Mosaic Backlog on native Postgres storage service | +| B | Supervisor — movement guarantee, two-agent floor, dispatch/claim | +| C | Planner — goal decomposition into independently-shippable cards | +| D | Merge-gate — single approver, pr-merge.sh after CI wait | +| E | Meta-loop — session-review + enhancer improvement PRs | +| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch | + +## Goals (backlog projection) + +| id | title | phase | priority | depends_on | +| --- | ---------------------------------------------------------------------- | ----- | ----------- | ---------- | +| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — | +| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | must-have | A1 | +| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | must-have | A2 | +| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | must-have | A3a | +| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | should-have | A3a | +| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 2 | must-have | A3a | +| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 2 | must-have | A3b, B1 | +| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 | +| B3b | Replan request on empty backlog; escalate on no-decompose | 2 | should-have | B3a | +| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 2 | must-have | B2 | + +## Assumptions (vetoable) + +- **ASM-1** (vetoable) — The Mosaic Backlog on the native Postgres storage service is the backlog of record. +- **ASM-2** (vetoable) — Claude gate roles have no native busy status, so readiness = pane-idle + heartbeat. +- **ASM-3** (vetoable) — Two-agent floor = 1 orchestrator + >=1 enhancer. + +## Spend + +- **advisory:** true +- No per-task token meter yet; budgets degrade to TTL. Spend is tracked only as an advisory projection alongside each card. diff --git a/docs/fleet/NORTH_STAR.yaml b/docs/fleet/NORTH_STAR.yaml new file mode 100644 index 0000000..0dba1b2 --- /dev/null +++ b/docs/fleet/NORTH_STAR.yaml @@ -0,0 +1,169 @@ +# Mosaic Fleet — NORTH_STAR (machine-readable source of truth) +# +# This file is the single machine-readable source of truth for fleet planning. +# Prose docs (including NORTH_STAR.md) are deterministic PROJECTIONS of this file. +# Regenerate the Markdown projection with the pure generator in +# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML, +# never the .md. +# +# Self-contained Mosaic. NO Hermes runtime dependency. The backlog of record is +# the Mosaic Backlog on Mosaic's OWN native Postgres storage service. + +version: 1 + +mission: >- + A self-driving Mosaic delivery fleet that 24/7 unattended converts a + machine-readable goal set into merged, CI-green, budget-bounded change — + looping plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN + native backlog/dispatch engine. + +substrate: + note: >- + The Mosaic Backlog is the backlog of record + dispatch engine, built on + Mosaic's native Postgres storage service (@mosaicstack/db drizzle; + PGlite-embedded by default, full Postgres by config). NOT Hermes. + +standing_objectives: + - id: NS-1 + text: >- + Single machine-readable source (this file) drives planning; prose docs are + projections. + - id: NS-2 + text: >- + Every backlog item is an independently-shippable unit with stable id, + priority, depends_on DAG, represented as a Mosaic Backlog card; spend + tracked as advisory projection. + - id: NS-3 + text: >- + The supervisor guarantees movement: no idle agent while ready + dependency-satisfied work exists; no empty backlog without a replan + request; assignment via Mosaic native dispatch/claim. + - id: NS-4 + text: >- + Exactly one merge-gate approver; nothing reaches main except via + pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the + backstop. + - id: NS-5 + text: >- + Every unit bounded by wall-clock TTL on its claim; token caps enforced + only where a real meter exists, else advisory. + - id: NS-6 + text: >- + Context cleared between tasks for ephemeral runners + (reset_between_tasks); persona+mission re-injected per task. + - id: NS-7 + text: >- + Meta-loop (session-review + enhancer) continuously proposes small + fleet-improvement PRs. + - id: NS-8 + text: >- + Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored + before every dispatch and every merge. + +success_criteria: + - id: AC-NS-1 + text: >- + The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) + healthy across reboot. + - id: AC-NS-2 + text: >- + A goal added to this YAML is decomposed to cards and either merged or + escalated, with no human in the loop. + - id: AC-NS-3 + text: >- + No PR merges with failure/error/no-status/timeout CI, and none bypass + pr-merge.sh. + - id: AC-NS-4 + text: >- + TTL is enforced on claims; token caps remain advisory until a real meter + exists. + - id: AC-NS-5 + text: >- + Flipping fleet/run/PAUSED halts dispatch and merges within one tick. + +workstreams: + - id: A + title: Substrate — Mosaic Backlog on native Postgres storage service + - id: B + title: Supervisor — movement guarantee, two-agent floor, dispatch/claim + - id: C + title: Planner — goal decomposition into independently-shippable cards + - id: D + title: Merge-gate — single approver, pr-merge.sh after CI wait + - id: E + title: Meta-loop — session-review + enhancer improvement PRs + - id: F + title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch + +goals: + - id: A1 + title: Machine-readable NORTH_STAR.yaml + Markdown projection + phase: 1 + priority: must-have + depends_on: [] + - id: A2 + title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite) + phase: 1 + priority: must-have + depends_on: [A1] + - id: A3a + title: Card lifecycle — create/claim/release with stable ids + depends_on DAG + phase: 1 + priority: must-have + depends_on: [A2] + - id: A3b + title: TTL-bounded claim enforcement (wall-clock) on cards + phase: 1 + priority: must-have + depends_on: [A3a] + - id: A4 + title: Advisory spend projection per card (degrades to TTL, no real meter) + phase: 1 + priority: should-have + depends_on: [A3a] + - id: B1 + title: Supervisor tick — readiness scan, two-agent-floor health check + phase: 2 + priority: must-have + depends_on: [A3a] + - id: B2 + title: Native dispatch/claim — assign ready dependency-satisfied work + phase: 2 + priority: must-have + depends_on: [A3b, B1] + - id: B3a + title: Planner decompose — goal added to YAML → cards + phase: 2 + priority: must-have + depends_on: [A2, B1] + - id: B3b + title: Replan request on empty backlog; escalate on no-decompose + phase: 2 + priority: should-have + depends_on: [B3a] + - id: G1 + title: PAUSE kill-switch + merge-gate honored before dispatch and merge + phase: 2 + priority: must-have + depends_on: [B2] + +assumptions: + - id: ASM-1 + vetoable: true + text: >- + The Mosaic Backlog on the native Postgres storage service is the backlog + of record. + - id: ASM-2 + vetoable: true + text: >- + Claude gate roles have no native busy status, so readiness = pane-idle + + heartbeat. + - id: ASM-3 + vetoable: true + text: 'Two-agent floor = 1 orchestrator + >=1 enhancer.' + +spend: + advisory: true + note: >- + No per-task token meter yet; budgets degrade to TTL. Spend is tracked only + as an advisory projection alongside each card. diff --git a/packages/mosaic/src/commands/fleet-north-star.spec.ts b/packages/mosaic/src/commands/fleet-north-star.spec.ts new file mode 100644 index 0000000..14bb08e --- /dev/null +++ b/packages/mosaic/src/commands/fleet-north-star.spec.ts @@ -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 { + return readFile(yamlPath, 'utf8'); +} + +async function loadParsed(): Promise { + 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'); + }); +}); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index a43a94b..8df4b90 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -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 | 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; + 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 | undefined; + + const spendRaw = parsed.spend as Record | 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; + 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; + 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; + 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 { + 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 [