omp-conductor 0.3.12 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -40,29 +40,138 @@ export const COMPOSE_BANNER = [
40
40
  /**
41
41
  * A brief split into the package's half and the operator's half.
42
42
  *
43
- * `shipped` runs to the end of the banner line; `owned` is everything after it.
44
- * Concatenating them reproduces the input byte for byte, which is what makes a
45
- * 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.
46
47
  */
47
48
  export interface BriefHalves {
48
49
  shipped: string;
49
50
  owned: string;
50
51
  }
51
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
+
52
150
  /**
53
151
  * Splits on the banner, or returns `undefined` when there is none.
54
152
  *
55
153
  * `undefined` is a real answer, not a failure: it means this brief cannot be
56
154
  * merged mechanically, and every caller is expected to degrade to reporting
57
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.
58
161
  */
59
162
  export function splitBrief(text: string): BriefHalves | undefined {
60
163
  const at = text.indexOf(EDIT_BANNER);
61
164
  if (at < 0) return undefined;
62
- // Keep the whole banner line on the shipped side: the operator's half starts
63
- // 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…
64
166
  const lineEnd = text.indexOf("\n", at);
65
- 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
+ }
66
175
  return { shipped: text.slice(0, cut), owned: text.slice(cut) };
67
176
  }
68
177
 
@@ -296,7 +405,9 @@ export function migrateToPolicy(opts: {
296
405
  if (owned === undefined) {
297
406
  throw new Error(`cannot migrate ${opts.orchestratorPath}: no ${EDIT_BANNER} banner`);
298
407
  }
299
- const policyBody = owned.replace(/^\s+/, "");
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+/, "");
300
411
  const policyBackup = writeWithBackup(opts.policyPath, policyBody.endsWith("\n") ? policyBody : `${policyBody}\n`);
301
412
  const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
302
413
  const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed);
@@ -309,6 +420,37 @@ export function migrateToPolicy(opts: {
309
420
  };
310
421
  }
311
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
+
312
454
  /**
313
455
  * Refresh composed ORCHESTRATOR.md from rendered floor + existing POLICY.md.
314
456
  * Creates nothing when POLICY.md is absent (caller should migrate first).
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  inspectBriefLayout,
18
18
  migrateToPolicy,
19
19
  proposeRetrofit,
20
+ repairPolicyBannerCrumbs,
20
21
  writeMergedBrief,
21
22
  } from "./brief-upgrade.ts";
22
23
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
@@ -593,7 +594,32 @@ try {
593
594
 
594
595
  if (argv.includes("--migrate")) {
595
596
  if (layout.kind === "overlay") {
596
- process.stdout.write(`${formatBriefStatus(path, layout)}\n`);
597
+ if (!argv.includes("--apply")) {
598
+ process.stdout.write(
599
+ `${formatBriefStatus(path, layout)}\n\n` +
600
+ "POLICY.md already present. --migrate --apply will strip any leading\n" +
601
+ "banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
602
+ );
603
+ break;
604
+ }
605
+ if (project === undefined) {
606
+ process.stderr.write(
607
+ "omp-conductor: repairing an overlay needs --project (or a config) so the floor renders.\n",
608
+ );
609
+ process.exit(1);
610
+ }
611
+ const repaired = repairPolicyBannerCrumbs({
612
+ orchestratorPath: layout.orchestratorPath,
613
+ policyPath: layout.policyPath,
614
+ floor: renderFloorForProject(project),
615
+ });
616
+ if (repaired === undefined) {
617
+ process.stdout.write(
618
+ `${formatBriefStatus(path, layout)}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
619
+ );
620
+ } else {
621
+ process.stdout.write(`${formatMigrateResult(repaired)}\n`);
622
+ }
597
623
  break;
598
624
  }
599
625
  if (layout.kind === "missing") {
package/src/plugin.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  formatMigrateResult,
19
19
  inspectBriefLayout,
20
20
  migrateToPolicy,
21
+ repairPolicyBannerCrumbs,
21
22
  writeMergedBrief,
22
23
  } from "./brief-upgrade.ts";
23
24
  import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
@@ -957,6 +958,23 @@ export default function conductorPlugin(pi: PluginApi): void {
957
958
  }),
958
959
  "info",
959
960
  );
961
+ const repair = await ctx.ui.confirm(
962
+ "Repair POLICY.md banner crumbs and recompose?",
963
+ "Strips any leading HTML-comment leftovers from a pre-fix migrate, then recomposes ORCHESTRATOR.md from the package floor + POLICY.md.",
964
+ );
965
+ if (repair) {
966
+ const repaired = repairPolicyBannerCrumbs({
967
+ orchestratorPath: layout.orchestratorPath,
968
+ policyPath: layout.policyPath,
969
+ floor: renderFloorForProject(p),
970
+ });
971
+ ctx.ui.notify(
972
+ repaired === undefined
973
+ ? "Recomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip."
974
+ : formatMigrateResult(repaired),
975
+ "info",
976
+ );
977
+ }
960
978
  break;
961
979
  }
962
980
  if (layout.kind === "legacy-bannered") {