omp-conductor 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,55 +1,177 @@
1
1
  /**
2
- * Keeping a live `ORCHESTRATOR.md` current with the shipped template.
2
+ * Package floor + fleet POLICY.md overlay.
3
3
  *
4
- * The wizard renders the template once and then never touches the file again,
5
- * because from that moment it is the operator's. That is the right ownership
6
- * rule and it has one consequence nobody notices until months later: every
7
- * later improvement to the *shipped* half of the brief — a new duty, a protocol
8
- * like the amendment loop — is invisible to every fleet already running. The
9
- * package updates; the standing prompt does not.
4
+ * The shipped orchestrator floor lives in the package and is re-rendered into a
5
+ * composed `ORCHESTRATOR.md` on every tick. Fleet-specific policy lives in
6
+ * `POLICY.md` and is the only file Learning-loop / operator edits should touch.
10
7
  *
11
- * This module is the missing half of that story. It never guesses: the brief has
12
- * an explicit banner separating the package's text from the operator's, so when
13
- * that banner is present the split is exact and the merge is mechanical. When it
14
- * is absent — a hand-written brief, or one predating the split — there is no
15
- * honest way to know which lines are the operator's, so nothing is rewritten and
16
- * the missing sections are reported instead.
8
+ * Legacy single-file briefs with a `YOURS TO EDIT` banner still split exactly;
9
+ * `migrate` lifts the owned half into `POLICY.md`. Hand-written briefs without a
10
+ * banner can `retrofit` one at a classified cut before migrating.
17
11
  */
18
12
 
19
- import { readFileSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
20
15
 
21
16
  /**
22
17
  * The line that divides the two halves. Matched on this substring rather than
23
18
  * the whole comment banner so a reflowed or re-decorated banner still splits.
24
19
  */
25
- const EDIT_BANNER = "YOURS TO EDIT";
20
+ export const EDIT_BANNER = "YOURS TO EDIT";
21
+
22
+ /** Fleet-owned overlay beside the composed orchestrator brief. */
23
+ export const POLICY_BRIEF_NAME = "POLICY.md";
24
+
25
+ /** Composed view the session / AGENTS.md symlink historically pointed at. */
26
+ export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
27
+
28
+ /** Topic keys that belong in POLICY.md (matched like {@link topicKey}). */
29
+ export const OWNED_TOPIC_KEYS = ["releases", "project context", "reporting", "amendments"] as const;
30
+
31
+ /** Banner written into composed ORCHESTRATOR.md between floor and policy. */
32
+ export const COMPOSE_BANNER = [
33
+ "<!-- ==================================================================== -->",
34
+ "<!-- YOURS TO EDIT — live copy of POLICY.md. Edit POLICY.md, not here. -->",
35
+ "<!-- This composed ORCHESTRATOR.md is regenerated from package floor + -->",
36
+ "<!-- POLICY.md; hand-edits above or below the banner will not last. -->",
37
+ "<!-- ==================================================================== -->",
38
+ ].join("\n");
26
39
 
27
40
  /**
28
41
  * A brief split into the package's half and the operator's half.
29
42
  *
30
- * `shipped` runs to the end of the banner line; `owned` is everything after it.
31
- * Concatenating them reproduces the input byte for byte, which is what makes a
32
- * merge safe to write back.
43
+ * `shipped` runs through the full contiguous HTML-comment banner that contains
44
+ * {@link EDIT_BANNER}; `owned` is everything after that block. Concatenating
45
+ * them reproduces the input byte for byte, which is what makes a merge safe to
46
+ * write back.
33
47
  */
34
48
  export interface BriefHalves {
35
49
  shipped: string;
36
50
  owned: string;
37
51
  }
38
52
 
53
+ /** True when a line is a single HTML comment (the banner's only vocabulary). */
54
+ export function isHtmlCommentLine(line: string): boolean {
55
+ const trimmed = line.trim();
56
+ return trimmed.startsWith("<!--") && trimmed.endsWith("-->");
57
+ }
58
+
59
+ /**
60
+ * Substrings that identify **package** banner chrome phrases, never operator
61
+ * notes and never a bare decorative separator.
62
+ *
63
+ * Matched case-insensitively inside an HTML comment. Keep this list tight: a
64
+ * false positive would delete fleet-owned POLICY prose.
65
+ */
66
+ const BANNER_CHROME_MARKERS = [
67
+ EDIT_BANNER,
68
+ "never reads this file back",
69
+ "live copy of POLICY.md",
70
+ "regenerated from package floor",
71
+ "hand-edits above or below",
72
+ ] as const;
73
+
74
+ /** `<!-- ====...==== -->` / dash separators that *frame* the banner. */
75
+ const BANNER_SEPARATOR = /^<!--\s*[=-]{3,}\s*-->$/;
76
+
77
+ /** True when a line is a decorative `<!-- === -->` / `<!-- --- -->` separator. */
78
+ export function isBannerSeparatorLine(line: string): boolean {
79
+ return BANNER_SEPARATOR.test(line.trim());
80
+ }
81
+
82
+ /**
83
+ * True when a line is a package banner **phrase** comment (YOURS TO EDIT /
84
+ * never-reads footer / compose-banner wording) — not a bare separator.
85
+ */
86
+ export function isPhraseChromeLine(line: string): boolean {
87
+ const trimmed = line.trim();
88
+ if (!isHtmlCommentLine(trimmed)) return false;
89
+ if (isBannerSeparatorLine(trimmed)) return false;
90
+ const lower = trimmed.toLowerCase();
91
+ return BANNER_CHROME_MARKERS.some((marker) => lower.includes(marker.toLowerCase()));
92
+ }
93
+
94
+ /**
95
+ * @deprecated Prefer {@link isPhraseChromeLine}. Separators alone are not chrome.
96
+ */
97
+ export function isKnownBannerChromeLine(line: string): boolean {
98
+ return isPhraseChromeLine(line);
99
+ }
100
+
101
+ /**
102
+ * Drop a leading **known package footer fragment** from an owned half / POLICY.md.
103
+ *
104
+ * Only strips when the leading blank+comment prefix contains at least one
105
+ * package phrase marker (e.g. "never reads this file back"). Separators like
106
+ * `<!-- ==== -->` strip only as companions to that phrase — a POLICY that
107
+ * legitimately starts with a decorative separator alone is left untouched.
108
+ * Stops at the first non-blank line that is neither a phrase nor a separator,
109
+ * so `<!-- operator notes -->` survive.
110
+ */
111
+ export function stripLeadingBannerCrumbs(text: string): string {
112
+ const lines = text.split("\n");
113
+
114
+ // Peek: does the leading blank/comment prefix carry a package phrase?
115
+ let j = 0;
116
+ let phraseInPrefix = false;
117
+ while (j < lines.length) {
118
+ const line = lines[j];
119
+ if (line === undefined) break;
120
+ const trimmed = line.trim();
121
+ if (trimmed === "") {
122
+ j += 1;
123
+ continue;
124
+ }
125
+ if (!isHtmlCommentLine(line)) break;
126
+ if (isPhraseChromeLine(line)) phraseInPrefix = true;
127
+ j += 1;
128
+ }
129
+ // Separator-only (or operator-comment-only) prefixes are fleet-owned — keep.
130
+ if (!phraseInPrefix) return text;
131
+
132
+ // Strip blanks, phrase chrome, and separators that framed that phrase.
133
+ let i = 0;
134
+ while (i < lines.length) {
135
+ const line = lines[i];
136
+ if (line === undefined) break;
137
+ const trimmed = line.trim();
138
+ if (trimmed === "" || isPhraseChromeLine(line) || isBannerSeparatorLine(line)) {
139
+ i += 1;
140
+ continue;
141
+ }
142
+ break;
143
+ }
144
+ return lines.slice(i).join("\n");
145
+ }
146
+
147
+ /** @deprecated Use {@link stripLeadingBannerCrumbs}. Kept for call-site stability. */
148
+ export const stripLeadingHtmlComments = stripLeadingBannerCrumbs;
149
+
39
150
  /**
40
151
  * Splits on the banner, or returns `undefined` when there is none.
41
152
  *
42
153
  * `undefined` is a real answer, not a failure: it means this brief cannot be
43
154
  * merged mechanically, and every caller is expected to degrade to reporting
44
155
  * rather than to assume a boundary.
156
+ *
157
+ * The cut is the end of the **whole contiguous HTML-comment block** that
158
+ * contains `YOURS TO EDIT`, not merely the line with that substring. Legacy and
159
+ * compose banners both trail the marker with more `<!-- … -->` lines; leaving
160
+ * those in `owned` would write package chrome into POLICY.md.
45
161
  */
46
162
  export function splitBrief(text: string): BriefHalves | undefined {
47
163
  const at = text.indexOf(EDIT_BANNER);
48
164
  if (at < 0) return undefined;
49
- // Keep the whole banner line on the shipped side: the operator's half starts
50
- // at the first line they own, so a merge never has to reconstruct the banner.
165
+ // End of the line that carries YOURS TO EDIT…
51
166
  const lineEnd = text.indexOf("\n", at);
52
- const cut = lineEnd < 0 ? text.length : lineEnd + 1;
167
+ let cut = lineEnd < 0 ? text.length : lineEnd + 1;
168
+ // …then every contiguous HTML-comment line after it (footer / closer).
169
+ while (cut < text.length) {
170
+ const nextNl = text.indexOf("\n", cut);
171
+ const nextLine = nextNl < 0 ? text.slice(cut) : text.slice(cut, nextNl);
172
+ if (!isHtmlCommentLine(nextLine)) break;
173
+ cut = nextNl < 0 ? text.length : nextNl + 1;
174
+ }
53
175
  return { shipped: text.slice(0, cut), owned: text.slice(cut) };
54
176
  }
55
177
 
@@ -65,25 +187,22 @@ function headings(text: string): string[] {
65
187
  /**
66
188
  * The comparable part of a heading: everything before the first dash, colon or
67
189
  * bracket, lowercased.
68
- *
69
- * Operators retitle sections freely — `## Reporting` becomes `## Reporting (low
70
- * noise, evidence-backed)`, `## Duty 1 — drain` becomes `## Duty 1 — the dispatch
71
- * loop (run this on every tick)` — and an exact match would report all of those as
72
- * absent. Ten reported sections when four are genuinely missing is a list nobody
73
- * reads, which is the same as reporting nothing.
74
190
  */
75
- function topicKey(heading: string): string {
191
+ export function topicKey(heading: string): string {
76
192
  const cut = heading.search(/[—–:(-]/u);
77
193
  return (cut < 0 ? heading : heading.slice(0, cut)).trim().toLowerCase();
78
194
  }
79
195
 
196
+ /** True when a heading's topic is one of the owned POLICY sections. */
197
+ export function isOwnedTopic(heading: string): boolean {
198
+ const key = topicKey(heading);
199
+ return (OWNED_TOPIC_KEYS as readonly string[]).includes(key);
200
+ }
201
+
80
202
  /**
81
203
  * Shipped sections the live brief has no heading for.
82
204
  *
83
- * Matched on {@link topicKey}, so a retitled section counts as present. The
84
- * remaining bias is deliberate: this decides what to *offer* for a hand-merge, and
85
- * a section reported that the operator already covers costs them one read, while a
86
- * new protocol silently counted as present costs them the protocol.
205
+ * Matched on {@link topicKey}, so a retitled section counts as present.
87
206
  */
88
207
  export function missingSections(live: string, rendered: string): string[] {
89
208
  const present = new Set(headings(live).map(topicKey));
@@ -108,6 +227,19 @@ export function sectionText(rendered: string, heading: string): string {
108
227
 
109
228
  /** An unfilled `{{KEY}}` coordinate in a template nobody rendered. */
110
229
  const PLACEHOLDER_PATTERN = /\{\{[A-Za-z0-9_]+\}\}/;
230
+ const PLACEHOLDER_REPLACE = /\{\{([A-Za-z0-9_]+)\}\}/g;
231
+
232
+ /**
233
+ * Tiny template renderer kept here so the tick path never imports `worker.ts`
234
+ * (and through it the session SDK).
235
+ */
236
+ export function renderBriefTemplate(template: string, vars: Record<string, string>): string {
237
+ return template.replace(PLACEHOLDER_REPLACE, (placeholder, key: string) => {
238
+ if (!Object.hasOwn(vars, key)) return placeholder;
239
+ const value = vars[key];
240
+ return value === undefined ? placeholder : value;
241
+ });
242
+ }
111
243
 
112
244
  /** What a check found, and what a caller may do about it. */
113
245
  export type BriefStatus =
@@ -117,29 +249,31 @@ export type BriefStatus =
117
249
  /** No banner, so the boundary is unknown and only reporting is honest. */
118
250
  | { kind: "unsplittable"; missing: string[] }
119
251
  /** Template never rendered, so merging it would write `{{PROJECT}}` into a brief. */
120
- | { kind: "unrendered"; missing: string[] };
252
+ | { kind: "unrendered"; missing: string[] }
253
+ /** Overlay already active: floor refreshes from package; policy is POLICY.md. */
254
+ | { kind: "overlay"; policyPath: string; orchestratorPath: string };
121
255
 
122
256
  /**
123
257
  * Compares a live brief against the freshly rendered template.
124
258
  *
125
- * `rendered` should come from `renderBriefForProject`, so the coordinates already
126
- * match and a diff reflects policy changes rather than substitution noise. A raw
127
- * template is accepted — a host that runs only the supervising session has no
128
- * config to render from — but it can only ever produce a report.
259
+ * Prefer {@link inspectBriefLayout} once a fleet has `POLICY.md`. This path
260
+ * remains for pre-overlay single-file briefs.
129
261
  */
130
262
  export function checkBrief(live: string, rendered: string): BriefStatus {
131
263
  const liveHalves = splitBrief(live);
132
264
  const freshHalves = splitBrief(rendered);
133
265
 
134
- // A template without the banner is a packaging error, not an operator problem,
135
- // so treat it the same as an unmergeable live brief rather than inventing a cut.
136
- if (liveHalves === undefined || freshHalves === undefined) {
266
+ // A template without the banner is expected in the overlay world (floor-only).
267
+ // When the *live* brief still has a banner, compare using composed rendered text
268
+ // that includes the compose banner so migrate remains available.
269
+ if (liveHalves === undefined) {
270
+ return { kind: "unsplittable", missing: missingSections(live, rendered) };
271
+ }
272
+ if (freshHalves === undefined) {
273
+ // Floor-only rendered template: live bannered brief wants migrate, not merge.
137
274
  return { kind: "unsplittable", missing: missingSections(live, rendered) };
138
275
  }
139
276
 
140
- // Enforced here rather than at each caller: merging an unrendered template would
141
- // write `{{PROJECT}}` into a live standing prompt, and a session reading its own
142
- // coordinates as a literal placeholder is worse than an out-of-date brief.
143
277
  if (PLACEHOLDER_PATTERN.test(freshHalves.shipped)) {
144
278
  return { kind: "unrendered", missing: missingSections(live, rendered) };
145
279
  }
@@ -148,20 +282,65 @@ export function checkBrief(live: string, rendered: string): BriefStatus {
148
282
 
149
283
  return {
150
284
  kind: "mergeable",
151
- // The operator's half is carried across untouched. This is the whole safety
152
- // property: an upgrade that reformats one of their sections is an upgrade
153
- // nobody runs twice.
154
285
  merged: freshHalves.shipped + liveHalves.owned,
155
286
  liveShipped: liveHalves.shipped,
156
287
  freshShipped: freshHalves.shipped,
157
288
  };
158
289
  }
159
290
 
291
+ /** Layout of brief files under a workspace root. */
292
+ export type BriefLayout =
293
+ | { kind: "overlay"; policyPath: string; orchestratorPath: string }
294
+ | { kind: "legacy-bannered"; orchestratorPath: string; owned: string }
295
+ | { kind: "legacy-handwritten"; orchestratorPath: string; missing: string[] }
296
+ | { kind: "missing" };
297
+
298
+ export function policyPathForRoot(workspaceRoot: string): string {
299
+ return join(workspaceRoot, POLICY_BRIEF_NAME);
300
+ }
301
+
302
+ export function orchestratorPathForRoot(workspaceRoot: string): string {
303
+ return join(workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
304
+ }
305
+
306
+ /**
307
+ * Classifies what sits in the workspace: overlay, migratable legacy, or absent.
308
+ *
309
+ * `floorHeadings` is the rendered floor (or composed) heading list used only to
310
+ * report missing sections for handwritten briefs.
311
+ */
312
+ export function inspectBriefLayout(
313
+ workspaceRoot: string,
314
+ floorOrComposedForReport: string,
315
+ ): BriefLayout {
316
+ const policyPath = policyPathForRoot(workspaceRoot);
317
+ const orchestratorPath = orchestratorPathForRoot(workspaceRoot);
318
+ if (existsSync(policyPath)) {
319
+ return { kind: "overlay", policyPath, orchestratorPath };
320
+ }
321
+ if (!existsSync(orchestratorPath)) return { kind: "missing" };
322
+ const live = readFileSync(orchestratorPath, "utf8");
323
+ const halves = splitBrief(live);
324
+ if (halves !== undefined) {
325
+ return { kind: "legacy-bannered", orchestratorPath, owned: halves.owned };
326
+ }
327
+ return {
328
+ kind: "legacy-handwritten",
329
+ orchestratorPath,
330
+ missing: missingSections(live, floorOrComposedForReport),
331
+ };
332
+ }
333
+
334
+ /** Join rendered floor + live policy into the composed session brief. */
335
+ export function composeOrchestrator(floor: string, policy: string): string {
336
+ const f = floor.replace(/\s+$/, "\n");
337
+ const p = policy.replace(/^\s+/, "").replace(/\s+$/, "\n");
338
+ return `${f}\n${COMPOSE_BANNER}\n\n${p}`;
339
+ }
340
+
160
341
  /**
161
342
  * Line-level diff of the two shipped halves, for a human to read before saying
162
- * yes. Deliberately not a real diff algorithm: the shipped half changes by whole
163
- * sections between versions, so listing removed and added lines in order is both
164
- * enough to review and impossible to misread as a merge preview.
343
+ * yes.
165
344
  */
166
345
  export function shippedDiff(before: string, after: string): string {
167
346
  const old = new Set(before.split("\n"));
@@ -176,22 +355,229 @@ export function shippedDiff(before: string, after: string): string {
176
355
  return lines.join("\n");
177
356
  }
178
357
 
358
+ /**
359
+ * Writes content, leaving the previous file beside it when one existed.
360
+ */
361
+ export function writeWithBackup(path: string, content: string): string | undefined {
362
+ mkdirSync(dirname(path), { recursive: true });
363
+ let backup: string | undefined;
364
+ if (existsSync(path)) {
365
+ backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
366
+ writeFileSync(backup, readFileSync(path));
367
+ }
368
+ writeFileSync(path, content);
369
+ return backup;
370
+ }
371
+
179
372
  /**
180
373
  * Writes the merged brief, leaving the previous one beside it.
181
374
  *
182
- * The backup is not optional and not configurable: this file is a standing
183
- * prompt an operator may have spent an hour on, and the one thing an upgrade
184
- * must never do is be the reason it is gone.
375
+ * @deprecated Prefer {@link migrateToPolicy} / overlay refresh. Kept for
376
+ * pre-overlay `--apply` on bannered single-file briefs.
185
377
  */
186
378
  export function writeMergedBrief(path: string, merged: string): string {
187
- const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
188
- writeFileSync(backup, readFileSync(path));
189
- writeFileSync(path, merged);
190
- return backup;
379
+ const backup = writeWithBackup(path, merged);
380
+ return backup ?? `${path}.bak-missing`;
381
+ }
382
+
383
+ /** Result of migrating a bannered ORCHESTRATOR.md into POLICY.md. */
384
+ export interface MigrateResult {
385
+ policyPath: string;
386
+ orchestratorPath: string;
387
+ policyBackup?: string;
388
+ orchestratorBackup?: string;
389
+ ownedBytes: number;
390
+ }
391
+
392
+ /**
393
+ * Lifts the owned half of a bannered orchestrator brief into `POLICY.md`, then
394
+ * writes a composed orchestrator from `floor` + that policy.
395
+ */
396
+ export function migrateToPolicy(opts: {
397
+ orchestratorPath: string;
398
+ policyPath: string;
399
+ floor: string;
400
+ /** When set, use this owned text instead of splitting the live file. */
401
+ owned?: string;
402
+ }): MigrateResult {
403
+ const live = readFileSync(opts.orchestratorPath, "utf8");
404
+ const owned = opts.owned ?? splitBrief(live)?.owned;
405
+ if (owned === undefined) {
406
+ throw new Error(`cannot migrate ${opts.orchestratorPath}: no ${EDIT_BANNER} banner`);
407
+ }
408
+ // Strip banner footers that an older split may have left in owned — never let
409
+ // package chrome become fleet policy.
410
+ const policyBody = stripLeadingBannerCrumbs(owned).replace(/^\s+/, "");
411
+ const policyBackup = writeWithBackup(opts.policyPath, policyBody.endsWith("\n") ? policyBody : `${policyBody}\n`);
412
+ const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
413
+ const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed);
414
+ return {
415
+ policyPath: opts.policyPath,
416
+ orchestratorPath: opts.orchestratorPath,
417
+ policyBackup,
418
+ orchestratorBackup,
419
+ ownedBytes: policyBody.length,
420
+ };
421
+ }
422
+
423
+ /**
424
+ * Strip leading banner-comment crumbs from an existing POLICY.md and recompose.
425
+ *
426
+ * For fleets that already migrated under the line-only split: `--migrate --apply`
427
+ * on an active overlay repairs POLICY.md in place rather than no-opping.
428
+ */
429
+ export function repairPolicyBannerCrumbs(opts: {
430
+ orchestratorPath: string;
431
+ policyPath: string;
432
+ floor: string;
433
+ }): MigrateResult | undefined {
434
+ if (!existsSync(opts.policyPath)) return undefined;
435
+ const before = readFileSync(opts.policyPath, "utf8");
436
+ const cleaned = stripLeadingBannerCrumbs(before);
437
+ if (cleaned === before) {
438
+ // Still recompose so the floor matches this package even when POLICY was clean.
439
+ writeFileSync(opts.orchestratorPath, composeOrchestrator(opts.floor, before));
440
+ return undefined;
441
+ }
442
+ const policyBackup = writeWithBackup(opts.policyPath, cleaned.endsWith("\n") ? cleaned : `${cleaned}\n`);
443
+ const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
444
+ const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed);
445
+ return {
446
+ policyPath: opts.policyPath,
447
+ orchestratorPath: opts.orchestratorPath,
448
+ policyBackup,
449
+ orchestratorBackup,
450
+ ownedBytes: cleaned.length,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Refresh composed ORCHESTRATOR.md from rendered floor + existing POLICY.md.
456
+ * Creates nothing when POLICY.md is absent (caller should migrate first).
457
+ */
458
+ export function refreshComposedBrief(opts: {
459
+ orchestratorPath: string;
460
+ policyPath: string;
461
+ floor: string;
462
+ }): boolean {
463
+ if (!existsSync(opts.policyPath)) return false;
464
+ const policy = readFileSync(opts.policyPath, "utf8");
465
+ writeFileSync(opts.orchestratorPath, composeOrchestrator(opts.floor, policy));
466
+ return true;
467
+ }
468
+
469
+ /** A proposed banner insertion for a hand-written brief (#20). */
470
+ export interface RetrofitProposal {
471
+ /** Byte offset in the live text where the banner block should be inserted. */
472
+ cut: number;
473
+ /** Heading that starts the owned half. */
474
+ atHeading: string;
475
+ /** Owned-topic headings (Releases / Project context / Reporting / Amendments). */
476
+ ownedHeadings: string[];
477
+ /** Non-owned headings that appear *before* the cut — stay on the floor side. */
478
+ floorAbove: string[];
479
+ /** Live text with the compose banner inserted at `cut`. */
480
+ retrofitted: string;
481
+ }
482
+
483
+ /**
484
+ * Why a retrofit cannot be applied automatically.
485
+ *
486
+ * `interleaved` means a floor-like heading (Duty, Learning loop, Hard boundaries,
487
+ * …) appears *below* the first owned-topic cut. Applying the banner there would
488
+ * push that floor section into POLICY.md on migrate — silent ownership theft.
489
+ */
490
+ export type RetrofitRefusal = {
491
+ kind: "interleaved";
492
+ atHeading: string;
493
+ ownedHeadings: string[];
494
+ floorAbove: string[];
495
+ floorBelow: string[];
496
+ };
497
+
498
+ export type RetrofitResult =
499
+ | { kind: "ok"; proposal: RetrofitProposal }
500
+ | { kind: "no-cut" }
501
+ | RetrofitRefusal;
502
+
503
+ /**
504
+ * Propose inserting the YOURS TO EDIT banner before the first owned-topic
505
+ * heading.
506
+ *
507
+ * Headings are classified by **position relative to that cut**, not globally:
508
+ * floor-like headings above the cut stay above; any floor-like heading below
509
+ * the cut is a refuse — the operator must reorder or hand-classify before apply.
510
+ */
511
+ export function proposeRetrofit(live: string): RetrofitResult {
512
+ const lines = live.split("\n");
513
+ const ownedHeadings: string[] = [];
514
+ const floorAbove: string[] = [];
515
+ const floorBelow: string[] = [];
516
+ let cutLine = -1;
517
+ let atHeading: string | undefined;
518
+ for (let i = 0; i < lines.length; i++) {
519
+ const line = lines[i];
520
+ if (line === undefined || !line.startsWith("## ")) continue;
521
+ const heading = line.slice(3).trim();
522
+ if (isOwnedTopic(heading)) {
523
+ ownedHeadings.push(heading);
524
+ if (cutLine < 0) {
525
+ cutLine = i;
526
+ atHeading = heading;
527
+ }
528
+ continue;
529
+ }
530
+ // Position relative to the (eventual) cut — headings before any owned topic
531
+ // are tentatively "above"; once the cut is known, later floor headings are
532
+ // "below" and block apply.
533
+ if (cutLine < 0) floorAbove.push(heading);
534
+ else floorBelow.push(heading);
535
+ }
536
+ if (cutLine < 0 || atHeading === undefined) return { kind: "no-cut" };
537
+
538
+ if (floorBelow.length > 0) {
539
+ return {
540
+ kind: "interleaved",
541
+ atHeading,
542
+ ownedHeadings,
543
+ floorAbove,
544
+ floorBelow,
545
+ };
546
+ }
547
+
548
+ // Byte offset: sum of prior lines + newlines.
549
+ let cut = 0;
550
+ for (let i = 0; i < cutLine; i++) {
551
+ const line = lines[i];
552
+ cut += (line?.length ?? 0) + 1;
553
+ }
554
+ const retrofitted = `${live.slice(0, cut)}${COMPOSE_BANNER}\n\n${live.slice(cut)}`;
555
+ return {
556
+ kind: "ok",
557
+ proposal: { cut, atHeading, ownedHeadings, floorAbove, retrofitted },
558
+ };
559
+ }
560
+
561
+ /** Insert the banner into a hand-written brief (with backup). */
562
+ export function applyRetrofit(path: string, proposal: RetrofitProposal): string {
563
+ const backup = writeWithBackup(path, proposal.retrofitted);
564
+ return backup ?? `${path}.bak-missing`;
191
565
  }
192
566
 
193
567
  /** The check rendered for a terminal, including what to do next. */
194
568
  export function formatBriefStatus(path: string, status: BriefStatus): string {
569
+ if (status.kind === "overlay") {
570
+ return [
571
+ `brief overlay active`,
572
+ "",
573
+ ` floor package template → recomposed into ${status.orchestratorPath} each tick`,
574
+ ` policy ${status.policyPath} (Learning loop / operator edits)`,
575
+ "",
576
+ "Protocol updates: npm install omp-conductor@… and restart — no brief-upgrade --apply.",
577
+ "Legacy migrate: omp-conductor brief-upgrade --migrate",
578
+ ].join("\n");
579
+ }
580
+
195
581
  if (status.kind === "current") {
196
582
  return [`brief ${path}`, "", "up to date — its shipped half matches this version of the template."].join("\n");
197
583
  }
@@ -200,13 +586,13 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
200
586
  return [
201
587
  `brief ${path}`,
202
588
  "",
203
- "This version of the package ships a different brief above the YOURS TO EDIT",
204
- "banner. Everything below the banner is yours and would be carried across",
205
- "unchanged.",
589
+ "Legacy single-file brief: this version ships a different half above the YOURS TO EDIT",
590
+ "banner. Prefer migrating to the POLICY.md overlay:",
206
591
  "",
207
592
  shippedDiff(status.liveShipped, status.freshShipped),
208
593
  "",
209
- "Apply it with: omp-conductor brief-upgrade --apply",
594
+ "Migrate: omp-conductor brief-upgrade --migrate",
595
+ "Or apply the old single-file merge: omp-conductor brief-upgrade --apply",
210
596
  "The previous file is kept beside it as ORCHESTRATOR.md.bak-<timestamp>.",
211
597
  ].join("\n");
212
598
  }
@@ -224,6 +610,11 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
224
610
  "This brief has no YOURS TO EDIT banner, so it was written by hand or predates",
225
611
  "the template split. There is no way to tell which lines are yours, so nothing",
226
612
  "will be rewritten automatically.",
613
+ "",
614
+ "Retrofit a banner at the first Releases/Project context/Reporting/Amendments",
615
+ "heading, then migrate:",
616
+ " omp-conductor brief-upgrade --retrofit",
617
+ " omp-conductor brief-upgrade --migrate",
227
618
  ]),
228
619
  );
229
620
  if (status.missing.length === 0) {
@@ -240,3 +631,57 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
240
631
  );
241
632
  return lines.join("\n");
242
633
  }
634
+
635
+ export function formatRetrofitProposal(path: string, proposal: RetrofitProposal): string {
636
+ return [
637
+ `retrofit ${path}`,
638
+ "",
639
+ `Insert the YOURS TO EDIT banner before ## ${proposal.atHeading}.`,
640
+ "",
641
+ `Owned-topic headings (${proposal.ownedHeadings.length}):`,
642
+ ...proposal.ownedHeadings.map((h) => ` - ${h}`),
643
+ `Floor-like headings above the banner (${proposal.floorAbove.length}):`,
644
+ ...(proposal.floorAbove.length === 0
645
+ ? [" (none)"]
646
+ : proposal.floorAbove.map((h) => ` - ${h}`)),
647
+ "",
648
+ "Apply: omp-conductor brief-upgrade --retrofit --apply",
649
+ "Then: omp-conductor brief-upgrade --migrate",
650
+ ].join("\n");
651
+ }
652
+
653
+ export function formatRetrofitRefusal(path: string, refusal: RetrofitRefusal): string {
654
+ return [
655
+ `retrofit ${path}`,
656
+ "",
657
+ `Refused: floor-like heading(s) appear below the proposed cut at ## ${refusal.atHeading}.`,
658
+ "Applying the banner here would put those sections into POLICY.md on migrate.",
659
+ "",
660
+ `Owned-topic headings (${refusal.ownedHeadings.length}):`,
661
+ ...refusal.ownedHeadings.map((h) => ` - ${h}`),
662
+ `Floor-like headings above the cut (${refusal.floorAbove.length}):`,
663
+ ...(refusal.floorAbove.length === 0
664
+ ? [" (none)"]
665
+ : refusal.floorAbove.map((h) => ` - ${h}`)),
666
+ `Floor-like headings BELOW the cut — must move or reclassify (${refusal.floorBelow.length}):`,
667
+ ...refusal.floorBelow.map((h) => ` - ${h}`),
668
+ "",
669
+ "Reorder so all Duties / Hard boundaries / Learning loop sit above Releases,",
670
+ "or hand-insert the YOURS TO EDIT banner at the line you intend, then migrate.",
671
+ "Nothing was written.",
672
+ ].join("\n");
673
+ }
674
+
675
+ export function formatMigrateResult(result: MigrateResult): string {
676
+ return [
677
+ "migrated to POLICY.md overlay",
678
+ "",
679
+ ` policy ${result.policyPath} (${result.ownedBytes} bytes)`,
680
+ ...(result.policyBackup ? [` policy bak ${result.policyBackup}`] : []),
681
+ ` composed ${result.orchestratorPath}`,
682
+ ...(result.orchestratorBackup ? [` brief bak ${result.orchestratorBackup}`] : []),
683
+ "",
684
+ "Next ticks recompose ORCHESTRATOR.md from the package floor + POLICY.md.",
685
+ "Edit only POLICY.md going forward.",
686
+ ].join("\n");
687
+ }