/** * Package floor + fleet POLICY.md overlay. * * The shipped orchestrator floor lives in the package and is re-rendered into a * composed `ORCHESTRATOR.md` on every tick. Fleet-specific policy lives in * `POLICY.md` and is the only file Learning-loop / operator edits should touch. * * Legacy single-file briefs with a `YOURS TO EDIT` banner still split exactly; * `migrate` lifts the owned half into `POLICY.md`. Hand-written briefs without a * banner can `retrofit` one at a classified cut before migrating. */ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; import { backupTimestamp, copyToUniqueBackup } from "./backups.ts"; import { stateDir } from "./config.ts"; /** * The line that divides the two halves. Matched on this substring rather than * the whole comment banner so a reflowed or re-decorated banner still splits. */ export const EDIT_BANNER = "YOURS TO EDIT"; /** Fleet-owned overlay beside the composed orchestrator brief. */ export const POLICY_BRIEF_NAME = "POLICY.md"; /** Composed view the session / AGENTS.md symlink historically pointed at. */ export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md"; /** * Host-wide operator policy, one file applying to every project on this host. * * Lives beside `config.json` under the state root (see {@link sharedPolicyPath}) * because it is operator text that outlives any one project's workspace. */ export const SHARED_POLICY_BRIEF_NAME = "SHARED_POLICY.md"; /** * The symlink name a session's cwd auto-loads as its brief. omp reads * `AGENTS.md` from the session cwd, so every cwd conductor provisions — the * fleet pane's, the console's — carries a link at this name resolving to * whichever brief that session is meant to read. * * Lives here with the other brief file names rather than in the host installer * that first needed it: two provisioners now write this link, and a second * spelling of the name is a session that silently loads nothing. */ export const AGENTS_BRIEF_NAME = "AGENTS.md"; /** * The console session's rendered floor, in the console cwd beside its * `AGENTS.md` link. * * Deliberately absent from {@link inspectBriefLayout}, `composeOrchestrator` * and `refreshComposedBrief`: the console floor is render-only, has no * `POLICY.md` half, and must never be classified as a migratable legacy brief. * The name lives here so the writer and the link agree on it; the layout * machinery above it does not know this file exists. */ export const CONSOLE_BRIEF_NAME = "CONSOLE.md"; /** Topic keys that belong in POLICY.md (matched like {@link topicKey}). */ export const OWNED_TOPIC_KEYS = ["releases", "project context", "reporting", "amendments"] as const; /** Banner written into composed ORCHESTRATOR.md between floor and policy. */ export const COMPOSE_BANNER = [ "", "", "", "", "", ].join("\n"); /** * Banner for the three-layer compose: names every source so a reader can tell * which layer a paragraph came from, and states the precedence — the project's * own `POLICY.md` overrides the host-wide shared layer where they conflict. * * Only used when a shared policy actually exists. With none, composition is * byte-identical to the two-layer `COMPOSE_BANNER` form above (#363). */ export const COMPOSE_BANNER_WITH_SHARED = [ "", "", "", "", "", "", "", "", "", "", ].join("\n"); /** * Divider between the shared layer and the project's own `POLICY.md` half. * * Carries its own edit marker so the two operator layers each end at a "yours * to edit" boundary instead of running into each other. */ export const PROJECT_POLICY_DIVIDER = [ "", "", "", ].join("\n"); /** * A brief split into the package's half and the operator's half. * * `shipped` runs through the full contiguous HTML-comment banner that contains * {@link EDIT_BANNER}; `owned` is everything after that block. Concatenating * them reproduces the input byte for byte, which is what makes a merge safe to * write back. */ export interface BriefHalves { shipped: string; owned: string; } /** True when a line is a single HTML comment (the banner's only vocabulary). */ export function isHtmlCommentLine(line: string): boolean { const trimmed = line.trim(); return trimmed.startsWith(""); } /** * Substrings that identify **package** banner chrome phrases, never operator * notes and never a bare decorative separator. * * Matched case-insensitively inside an HTML comment. Keep this list tight: a * false positive would delete fleet-owned POLICY prose. */ const BANNER_CHROME_MARKERS = [ EDIT_BANNER, "never reads this file back", "live copy of POLICY.md", "regenerated from package floor", "hand-edits above or below", ] as const; /** `` / dash separators that *frame* the banner. */ const BANNER_SEPARATOR = /^$/; /** True when a line is a decorative `` / `` separator. */ export function isBannerSeparatorLine(line: string): boolean { return BANNER_SEPARATOR.test(line.trim()); } /** * True when a line is a package banner **phrase** comment (YOURS TO EDIT / * never-reads footer / compose-banner wording) — not a bare separator. */ export function isPhraseChromeLine(line: string): boolean { const trimmed = line.trim(); if (!isHtmlCommentLine(trimmed)) return false; if (isBannerSeparatorLine(trimmed)) return false; const lower = trimmed.toLowerCase(); return BANNER_CHROME_MARKERS.some((marker) => lower.includes(marker.toLowerCase())); } /** * Drop a leading **known package footer fragment** from an owned half / POLICY.md. * * Only strips when the leading blank+comment prefix contains at least one * package phrase marker (e.g. "never reads this file back"). Separators like * `` strip only as companions to that phrase — a POLICY that * legitimately starts with a decorative separator alone is left untouched. * Stops at the first non-blank line that is neither a phrase nor a separator, * so `` survive. */ export function stripLeadingBannerCrumbs(text: string): string { const lines = text.split("\n"); // Peek: does the leading blank/comment prefix carry a package phrase? let j = 0; let phraseInPrefix = false; while (j < lines.length) { const line = lines[j]; if (line === undefined) break; const trimmed = line.trim(); if (trimmed === "") { j += 1; continue; } if (!isHtmlCommentLine(line)) break; if (isPhraseChromeLine(line)) phraseInPrefix = true; j += 1; } // Separator-only (or operator-comment-only) prefixes are fleet-owned — keep. if (!phraseInPrefix) return text; // Strip blanks, phrase chrome, and separators that framed that phrase. let i = 0; while (i < lines.length) { const line = lines[i]; if (line === undefined) break; const trimmed = line.trim(); if (trimmed === "" || isPhraseChromeLine(line) || isBannerSeparatorLine(line)) { i += 1; continue; } break; } return lines.slice(i).join("\n"); } /** * Splits on the banner, or returns `undefined` when there is none. * * `undefined` is a real answer, not a failure: it means this brief cannot be * merged mechanically, and every caller is expected to degrade to reporting * rather than to assume a boundary. * * The cut is the end of the **whole contiguous HTML-comment block** that * contains `YOURS TO EDIT`, not merely the line with that substring. Legacy and * compose banners both trail the marker with more `` lines; leaving * those in `owned` would write package chrome into POLICY.md. */ export function splitBrief(text: string): BriefHalves | undefined { const at = text.indexOf(EDIT_BANNER); if (at < 0) return undefined; // End of the line that carries YOURS TO EDIT… const lineEnd = text.indexOf("\n", at); let cut = lineEnd < 0 ? text.length : lineEnd + 1; // …then every contiguous HTML-comment line after it (footer / closer). while (cut < text.length) { const nextNl = text.indexOf("\n", cut); const nextLine = nextNl < 0 ? text.slice(cut) : text.slice(cut, nextNl); if (!isHtmlCommentLine(nextLine)) break; cut = nextNl < 0 ? text.length : nextNl + 1; } return { shipped: text.slice(0, cut), owned: text.slice(cut) }; } /** A `## Heading` in the shipped half, by its exact text. */ function headings(text: string): string[] { const out: string[] = []; for (const line of text.split("\n")) { if (line.startsWith("## ")) out.push(line.slice(3).trim()); } return out; } /** * The comparable part of a heading: everything before the first dash, colon or * bracket, lowercased. */ export function topicKey(heading: string): string { const cut = heading.search(/[—–:(-]/u); return (cut < 0 ? heading : heading.slice(0, cut)).trim().toLowerCase(); } /** True when a heading's topic is one of the owned POLICY sections. */ export function isOwnedTopic(heading: string): boolean { const key = topicKey(heading); return (OWNED_TOPIC_KEYS as readonly string[]).includes(key); } /** * Shipped sections the live brief has no heading for. * * Matched on {@link topicKey}, so a retitled section counts as present. */ export function missingSections(live: string, rendered: string): string[] { const present = new Set(headings(live).map(topicKey)); return headings(rendered).filter((h) => !present.has(topicKey(h))); } /** Section bodies from a rendered template, keyed by heading, for reporting. */ export function sectionText(rendered: string, heading: string): string { const lines = rendered.split("\n"); const start = lines.findIndex((l) => l.startsWith("## ") && l.slice(3).trim() === heading); if (start < 0) return ""; let end = lines.length; for (let i = start + 1; i < lines.length; i++) { const line = lines[i]; if (line !== undefined && line.startsWith("## ")) { end = i; break; } } return lines.slice(start, end).join("\n").trimEnd(); } /** An unfilled `{{KEY}}` coordinate in a template nobody rendered. */ const PLACEHOLDER_PATTERN = /\{\{[A-Za-z0-9_]+\}\}/; const PLACEHOLDER_REPLACE = /\{\{([A-Za-z0-9_]+)\}\}/g; /** * Tiny template renderer kept here so the tick path never imports `worker.ts` * (and through it the session SDK). */ export function renderBriefTemplate(template: string, vars: Record): string { return template.replace(PLACEHOLDER_REPLACE, (placeholder, key: string) => { if (!Object.hasOwn(vars, key)) return placeholder; const value = vars[key]; return value === undefined ? placeholder : value; }); } /** Layout of brief files under a workspace root. */ export type BriefLayout = | { kind: "overlay"; policyPath: string; orchestratorPath: string } | { kind: "legacy-bannered"; orchestratorPath: string; owned: string } | { kind: "legacy-handwritten"; orchestratorPath: string; missing: string[] } | { kind: "missing" }; export function policyPathForRoot(workspaceRoot: string): string { return join(workspaceRoot, POLICY_BRIEF_NAME); } export function orchestratorPathForRoot(workspaceRoot: string): string { return join(workspaceRoot, ORCHESTRATOR_BRIEF_NAME); } /** Where the host-wide shared policy lives: beside `config.json` (see stateDir). */ export function sharedPolicyPath(): string { return join(stateDir(), SHARED_POLICY_BRIEF_NAME); } /** * Shared policy text from the host-wide file, or `undefined` when it is absent. * * `undefined` is the "two-layer fleet" answer: absent shared policy composes * byte-identically to today, so callers pass this straight into * {@link composeOrchestrator}. */ export function readSharedPolicy(): string | undefined { const path = sharedPolicyPath(); return existsSync(path) ? readFileSync(path, "utf8") : undefined; } /** * Shared policy text from an explicitly-named file, or `undefined` when the * file does not exist. The refresh/migrate paths take the path as an option so * their tests stay hermetic; {@link readSharedPolicy} is the state-dir version. */ function readSharedAt(path: string | undefined): string | undefined { return path !== undefined && existsSync(path) ? readFileSync(path, "utf8") : undefined; } /** * Classifies what sits in the workspace: overlay, migratable legacy, or absent. * * `floorHeadings` is the rendered floor (or composed) heading list used only to * report missing sections for handwritten briefs. */ export function inspectBriefLayout( workspaceRoot: string, floorOrComposedForReport: string, ): BriefLayout { const policyPath = policyPathForRoot(workspaceRoot); const orchestratorPath = orchestratorPathForRoot(workspaceRoot); if (existsSync(policyPath)) { return { kind: "overlay", policyPath, orchestratorPath }; } if (!existsSync(orchestratorPath)) return { kind: "missing" }; const live = readFileSync(orchestratorPath, "utf8"); const halves = splitBrief(live); if (halves !== undefined) { return { kind: "legacy-bannered", orchestratorPath, owned: halves.owned }; } return { kind: "legacy-handwritten", orchestratorPath, missing: missingSections(live, floorOrComposedForReport), }; } /** * Join rendered floor + live policy into the composed session brief. * * An optional host-wide shared policy composes between the floor and the * project policy. Absent one (`undefined`), the output is byte-identical to * the two-layer form — a single-project fleet sees no change at all. */ export function composeOrchestrator(floor: string, policy: string, sharedPolicy?: string): string { const f = floor.replace(/\s+$/, "\n"); const p = policy.replace(/^\s+/, "").replace(/\s+$/, "\n"); if (sharedPolicy === undefined) return `${f}\n${COMPOSE_BANNER}\n\n${p}`; const s = sharedPolicy.replace(/^\s+/, "").replace(/\s+$/, "\n"); return `${f}\n${COMPOSE_BANNER_WITH_SHARED}\n\n${s}\n${PROJECT_POLICY_DIVIDER}\n\n${p}`; } /** * Line-level diff of the two shipped halves, for a human to read before saying * yes. */ export function shippedDiff(before: string, after: string): string { const old = new Set(before.split("\n")); const now = new Set(after.split("\n")); const lines: string[] = []; for (const line of before.split("\n")) { if (!now.has(line) && line.trim() !== "") lines.push(`- ${line}`); } for (const line of after.split("\n")) { if (!old.has(line) && line.trim() !== "") lines.push(`+ ${line}`); } return lines.join("\n"); } /** Dedicated state-root directory for conductor-managed brief backups. */ export function briefBackupDir(): string { return join(stateDir(), "backups", "briefs"); } /** * Moves only conductor's timestamp-shaped legacy sidecar backups into state. * Unknown `.bak` files remain operator-owned. Copy-before-unlink also works * when the workspace and state root are on different filesystems. */ export function migrateLegacyBriefBackups(paths: readonly string[], backupRoot = briefBackupDir()): string[] { const migrated: string[] = []; for (const path of paths) { const name = basename(path); const pattern = new RegExp( `^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.bak-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z$`, ); if (!existsSync(dirname(path))) continue; for (const entry of readdirSync(dirname(path), { withFileTypes: true })) { if (!entry.isFile() || !pattern.test(entry.name)) continue; const source = join(dirname(path), entry.name); const destination = copyToUniqueBackup(source, backupRoot, entry.name); unlinkSync(source); migrated.push(destination); } } return migrated; } /** Writes content and stores any previous file under conductor state. */ export function writeWithBackup( path: string, content: string, backupRoot = briefBackupDir(), ): string | undefined { mkdirSync(dirname(path), { recursive: true }); migrateLegacyBriefBackups([path], backupRoot); const backup = existsSync(path) ? copyToUniqueBackup(path, backupRoot, `${basename(path)}.bak-${backupTimestamp()}`) : undefined; writeFileSync(path, content); return backup; } /** Result of migrating a bannered ORCHESTRATOR.md into POLICY.md. */ export interface MigrateResult { policyPath: string; orchestratorPath: string; policyBackup?: string; orchestratorBackup?: string; ownedBytes: number; } /** * Lifts the owned half of a bannered orchestrator brief into `POLICY.md`, then * writes a composed orchestrator from `floor` + that policy. */ export function migrateToPolicy(opts: { orchestratorPath: string; policyPath: string; floor: string; /** When set, use this owned text instead of splitting the live file. */ owned?: string; /** Optional host-wide shared policy file; absent one composes two-layer. */ sharedPolicyPath?: string; /** Override the conductor backup directory (tests). */ backupRoot?: string; }): MigrateResult { const live = readFileSync(opts.orchestratorPath, "utf8"); const owned = opts.owned ?? splitBrief(live)?.owned; if (owned === undefined) { throw new Error(`cannot migrate ${opts.orchestratorPath}: no ${EDIT_BANNER} banner`); } // Strip banner footers that an older split may have left in owned — never let // package chrome become fleet policy. const policyBody = stripLeadingBannerCrumbs(owned).replace(/^\s+/, ""); const policyBackup = writeWithBackup( opts.policyPath, policyBody.endsWith("\n") ? policyBody : `${policyBody}\n`, opts.backupRoot, ); const composed = composeOrchestrator( opts.floor, readFileSync(opts.policyPath, "utf8"), readSharedAt(opts.sharedPolicyPath), ); const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed, opts.backupRoot); return { policyPath: opts.policyPath, orchestratorPath: opts.orchestratorPath, policyBackup, orchestratorBackup, ownedBytes: policyBody.length, }; } /** * Strip leading banner-comment crumbs from an existing POLICY.md and recompose. * * For fleets that already migrated under the line-only split: `--migrate --apply` * on an active overlay repairs POLICY.md in place rather than no-opping. */ export function repairPolicyBannerCrumbs(opts: { orchestratorPath: string; policyPath: string; floor: string; /** Optional host-wide shared policy file; absent one composes two-layer. */ sharedPolicyPath?: string; /** Override the conductor backup directory (tests). */ backupRoot?: string; }): MigrateResult | undefined { if (!existsSync(opts.policyPath)) return undefined; const before = readFileSync(opts.policyPath, "utf8"); const cleaned = stripLeadingBannerCrumbs(before); if (cleaned === before) { // Still recompose so the floor matches this package even when POLICY was clean. writeFileSync( opts.orchestratorPath, composeOrchestrator(opts.floor, before, readSharedAt(opts.sharedPolicyPath)), ); return undefined; } const policyBackup = writeWithBackup( opts.policyPath, cleaned.endsWith("\n") ? cleaned : `${cleaned}\n`, opts.backupRoot, ); const composed = composeOrchestrator( opts.floor, readFileSync(opts.policyPath, "utf8"), readSharedAt(opts.sharedPolicyPath), ); const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed, opts.backupRoot); return { policyPath: opts.policyPath, orchestratorPath: opts.orchestratorPath, policyBackup, orchestratorBackup, ownedBytes: cleaned.length, }; } /** * Refresh composed ORCHESTRATOR.md from rendered floor + existing POLICY.md. * Creates nothing when POLICY.md is absent (caller should migrate first). */ export function refreshComposedBrief(opts: { orchestratorPath: string; policyPath: string; floor: string; /** Optional host-wide shared policy file; absent one composes two-layer. */ sharedPolicyPath?: string; /** Override the conductor backup directory (tests). */ backupRoot?: string; }): boolean { migrateLegacyBriefBackups([opts.policyPath, opts.orchestratorPath], opts.backupRoot); if (!existsSync(opts.policyPath)) return false; const policy = readFileSync(opts.policyPath, "utf8"); writeFileSync( opts.orchestratorPath, composeOrchestrator(opts.floor, policy, readSharedAt(opts.sharedPolicyPath)), ); return true; } /** A proposed banner insertion for a hand-written brief (#20). */ export interface RetrofitProposal { /** Byte offset in the live text where the banner block should be inserted. */ cut: number; /** Heading that starts the owned half. */ atHeading: string; /** Owned-topic headings (Releases / Project context / Reporting / Amendments). */ ownedHeadings: string[]; /** Non-owned headings that appear *before* the cut — stay on the floor side. */ floorAbove: string[]; /** Live text with the compose banner inserted at `cut`. */ retrofitted: string; } /** * Why a retrofit cannot be applied automatically. * * `interleaved` means a floor-like heading (Duty, Learning loop, Hard boundaries, * …) appears *below* the first owned-topic cut. Applying the banner there would * push that floor section into POLICY.md on migrate — silent ownership theft. */ export type RetrofitRefusal = { kind: "interleaved"; atHeading: string; ownedHeadings: string[]; floorAbove: string[]; floorBelow: string[]; }; export type RetrofitResult = | { kind: "ok"; proposal: RetrofitProposal } | { kind: "no-cut" } | RetrofitRefusal; /** * Propose inserting the YOURS TO EDIT banner before the first owned-topic * heading. * * Headings are classified by **position relative to that cut**, not globally: * floor-like headings above the cut stay above; any floor-like heading below * the cut is a refuse — the operator must reorder or hand-classify before apply. */ export function proposeRetrofit(live: string): RetrofitResult { const lines = live.split("\n"); const ownedHeadings: string[] = []; const floorAbove: string[] = []; const floorBelow: string[] = []; let cutLine = -1; let atHeading: string | undefined; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line === undefined || !line.startsWith("## ")) continue; const heading = line.slice(3).trim(); if (isOwnedTopic(heading)) { ownedHeadings.push(heading); if (cutLine < 0) { cutLine = i; atHeading = heading; } continue; } // Position relative to the (eventual) cut — headings before any owned topic // are tentatively "above"; once the cut is known, later floor headings are // "below" and block apply. if (cutLine < 0) floorAbove.push(heading); else floorBelow.push(heading); } if (cutLine < 0 || atHeading === undefined) return { kind: "no-cut" }; if (floorBelow.length > 0) { return { kind: "interleaved", atHeading, ownedHeadings, floorAbove, floorBelow, }; } // Byte offset: sum of prior lines + newlines. let cut = 0; for (let i = 0; i < cutLine; i++) { const line = lines[i]; cut += (line?.length ?? 0) + 1; } const retrofitted = `${live.slice(0, cut)}${COMPOSE_BANNER}\n\n${live.slice(cut)}`; return { kind: "ok", proposal: { cut, atHeading, ownedHeadings, floorAbove, retrofitted }, }; } /** Insert the banner into a hand-written brief (with a state-root backup). */ export function applyRetrofit(path: string, proposal: RetrofitProposal, backupRoot?: string): string { const backup = writeWithBackup(path, proposal.retrofitted, backupRoot); return backup ?? `${path}.bak-missing`; } /** * The layout rendered for a terminal, including what to do next. * * One report for the four layouts `inspectBriefLayout` can return, because that * is now the only classifier. The pre-overlay pair it replaced described two * further states ("mergeable", "current") that only the deleted single-file * merge could act on (#131). * * `sharedPolicyPath` is the optional host-wide shared-policy file; the overlay * report lists it when present so the operator can see which layers compose. */ export function formatBriefReport( path: string, layout: BriefLayout, missing: readonly string[], sharedPolicyPath?: string, ): string { if (layout.kind === "overlay") { return [ `brief overlay active`, "", ` floor package template → recomposed into ${layout.orchestratorPath} each tick`, ` shared ${ sharedPolicyPath ?? "(none — composing package floor + POLICY.md)" } — host-wide operator policy; the project's POLICY.md overrides it`, ` policy ${layout.policyPath} (Learning loop / operator edits)`, "", "Protocol updates: upgrade omp-conductor in this host's existing install root (same package manager), then restart the daemon — no brief-upgrade --apply.", "Legacy migrate: omp-conductor brief-upgrade --migrate", ].join("\n"); } if (layout.kind === "missing") { return [ `brief ${path}`, "", "No brief here yet. Run `omp-conductor setup brief` and say yes to writing ORCHESTRATOR.md;", "it writes POLICY.md beside it, which is the half you then own.", ].join("\n"); } if (layout.kind === "legacy-bannered") { return [ `brief ${path}`, "", "Legacy single-file brief: one file with a YOURS TO EDIT banner, from before the", "floor and the policy were separate files.", "", "Migrate: omp-conductor brief-upgrade --migrate --apply", "", "That lifts everything below the banner into POLICY.md and recomposes", "ORCHESTRATOR.md from the package floor plus that policy — after which the floor", "tracks the installed package on its own. The previous file is kept under", `${briefBackupDir()}.`, ].join("\n"); } const lines = [ `brief ${path}`, "", "This brief has no YOURS TO EDIT banner, so it was written by hand or predates", "the template split. There is no way to tell which lines are yours, so nothing", "will be rewritten automatically.", "", "Retrofit a banner at the first Releases/Project context/Reporting/Amendments", "heading, then migrate:", " omp-conductor brief-upgrade --retrofit --apply", " omp-conductor brief-upgrade --migrate --apply", ]; if (missing.length === 0) { lines.push("", "It already has a heading for every section the template ships."); return lines.join("\n"); } lines.push( "", `Sections the shipped template has and this brief does not (${missing.length}):`, ...missing.map((h) => ` - ${h}`), "", "Merge the ones you want by hand, or ask the session running from this brief to", "propose them through its own amendment protocol.", ); return lines.join("\n"); } export function formatRetrofitProposal(path: string, proposal: RetrofitProposal): string { return [ `retrofit ${path}`, "", `Insert the YOURS TO EDIT banner before ## ${proposal.atHeading}.`, "", `Owned-topic headings (${proposal.ownedHeadings.length}):`, ...proposal.ownedHeadings.map((h) => ` - ${h}`), `Floor-like headings above the banner (${proposal.floorAbove.length}):`, ...(proposal.floorAbove.length === 0 ? [" (none)"] : proposal.floorAbove.map((h) => ` - ${h}`)), "", "Apply: omp-conductor brief-upgrade --retrofit --apply", "Then: omp-conductor brief-upgrade --migrate", ].join("\n"); } export function formatRetrofitRefusal(path: string, refusal: RetrofitRefusal): string { return [ `retrofit ${path}`, "", `Refused: floor-like heading(s) appear below the proposed cut at ## ${refusal.atHeading}.`, "Applying the banner here would put those sections into POLICY.md on migrate.", "", `Owned-topic headings (${refusal.ownedHeadings.length}):`, ...refusal.ownedHeadings.map((h) => ` - ${h}`), `Floor-like headings above the cut (${refusal.floorAbove.length}):`, ...(refusal.floorAbove.length === 0 ? [" (none)"] : refusal.floorAbove.map((h) => ` - ${h}`)), `Floor-like headings BELOW the cut — must move or reclassify (${refusal.floorBelow.length}):`, ...refusal.floorBelow.map((h) => ` - ${h}`), "", "Reorder so all Duties / Hard boundaries / Learning loop sit above Releases,", "or hand-insert the YOURS TO EDIT banner at the line you intend, then migrate.", "Nothing was written.", ].join("\n"); } export function formatMigrateResult(result: MigrateResult): string { return [ "migrated to POLICY.md overlay", "", ` policy ${result.policyPath} (${result.ownedBytes} bytes)`, ...(result.policyBackup ? [` policy bak ${result.policyBackup}`] : []), ` composed ${result.orchestratorPath}`, ...(result.orchestratorBackup ? [` brief bak ${result.orchestratorBackup}`] : []), "", "Next ticks recompose ORCHESTRATOR.md from the package floor + POLICY.md.", "Edit only POLICY.md going forward.", ].join("\n"); }