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.
- package/README.md +24 -35
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +7 -6
- package/src/brief-upgrade.ts +505 -60
- package/src/briefs/orchestrator.md +40 -123
- package/src/briefs/policy.md +89 -0
- package/src/cli.ts +140 -24
- package/src/daemon.ts +1 -1
- package/src/orchestrator-tick.ts +48 -6
- package/src/plugin.ts +77 -16
- package/src/setup.ts +104 -41
package/src/brief-upgrade.ts
CHANGED
|
@@ -1,55 +1,177 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Package floor + fleet POLICY.md overlay.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
31
|
-
*
|
|
32
|
-
* merge safe to
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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
|
|
135
|
-
//
|
|
136
|
-
|
|
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.
|
|
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
|
-
*
|
|
183
|
-
*
|
|
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 =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
"
|
|
204
|
-
"banner.
|
|
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
|
-
"
|
|
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
|
+
}
|