omp-conductor 0.3.9 → 0.3.12
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 +357 -54
- package/src/briefs/orchestrator.md +50 -127
- package/src/briefs/policy.md +89 -0
- package/src/cli.ts +114 -24
- package/src/daemon.ts +18 -9
- package/src/escalate.ts +26 -4
- package/src/orchestrator-tick.ts +90 -8
- package/src/plugin.ts +59 -16
- package/src/setup.ts +104 -41
- package/src/worktree.ts +67 -10
package/src/brief-upgrade.ts
CHANGED
|
@@ -1,28 +1,41 @@
|
|
|
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.
|
|
@@ -65,25 +78,22 @@ function headings(text: string): string[] {
|
|
|
65
78
|
/**
|
|
66
79
|
* The comparable part of a heading: everything before the first dash, colon or
|
|
67
80
|
* 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
81
|
*/
|
|
75
|
-
function topicKey(heading: string): string {
|
|
82
|
+
export function topicKey(heading: string): string {
|
|
76
83
|
const cut = heading.search(/[—–:(-]/u);
|
|
77
84
|
return (cut < 0 ? heading : heading.slice(0, cut)).trim().toLowerCase();
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/** True when a heading's topic is one of the owned POLICY sections. */
|
|
88
|
+
export function isOwnedTopic(heading: string): boolean {
|
|
89
|
+
const key = topicKey(heading);
|
|
90
|
+
return (OWNED_TOPIC_KEYS as readonly string[]).includes(key);
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/**
|
|
81
94
|
* Shipped sections the live brief has no heading for.
|
|
82
95
|
*
|
|
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.
|
|
96
|
+
* Matched on {@link topicKey}, so a retitled section counts as present.
|
|
87
97
|
*/
|
|
88
98
|
export function missingSections(live: string, rendered: string): string[] {
|
|
89
99
|
const present = new Set(headings(live).map(topicKey));
|
|
@@ -108,6 +118,19 @@ export function sectionText(rendered: string, heading: string): string {
|
|
|
108
118
|
|
|
109
119
|
/** An unfilled `{{KEY}}` coordinate in a template nobody rendered. */
|
|
110
120
|
const PLACEHOLDER_PATTERN = /\{\{[A-Za-z0-9_]+\}\}/;
|
|
121
|
+
const PLACEHOLDER_REPLACE = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Tiny template renderer kept here so the tick path never imports `worker.ts`
|
|
125
|
+
* (and through it the session SDK).
|
|
126
|
+
*/
|
|
127
|
+
export function renderBriefTemplate(template: string, vars: Record<string, string>): string {
|
|
128
|
+
return template.replace(PLACEHOLDER_REPLACE, (placeholder, key: string) => {
|
|
129
|
+
if (!Object.hasOwn(vars, key)) return placeholder;
|
|
130
|
+
const value = vars[key];
|
|
131
|
+
return value === undefined ? placeholder : value;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
111
134
|
|
|
112
135
|
/** What a check found, and what a caller may do about it. */
|
|
113
136
|
export type BriefStatus =
|
|
@@ -117,29 +140,31 @@ export type BriefStatus =
|
|
|
117
140
|
/** No banner, so the boundary is unknown and only reporting is honest. */
|
|
118
141
|
| { kind: "unsplittable"; missing: string[] }
|
|
119
142
|
/** Template never rendered, so merging it would write `{{PROJECT}}` into a brief. */
|
|
120
|
-
| { kind: "unrendered"; missing: string[] }
|
|
143
|
+
| { kind: "unrendered"; missing: string[] }
|
|
144
|
+
/** Overlay already active: floor refreshes from package; policy is POLICY.md. */
|
|
145
|
+
| { kind: "overlay"; policyPath: string; orchestratorPath: string };
|
|
121
146
|
|
|
122
147
|
/**
|
|
123
148
|
* Compares a live brief against the freshly rendered template.
|
|
124
149
|
*
|
|
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.
|
|
150
|
+
* Prefer {@link inspectBriefLayout} once a fleet has `POLICY.md`. This path
|
|
151
|
+
* remains for pre-overlay single-file briefs.
|
|
129
152
|
*/
|
|
130
153
|
export function checkBrief(live: string, rendered: string): BriefStatus {
|
|
131
154
|
const liveHalves = splitBrief(live);
|
|
132
155
|
const freshHalves = splitBrief(rendered);
|
|
133
156
|
|
|
134
|
-
// A template without the banner is
|
|
135
|
-
//
|
|
136
|
-
|
|
157
|
+
// A template without the banner is expected in the overlay world (floor-only).
|
|
158
|
+
// When the *live* brief still has a banner, compare using composed rendered text
|
|
159
|
+
// that includes the compose banner so migrate remains available.
|
|
160
|
+
if (liveHalves === undefined) {
|
|
161
|
+
return { kind: "unsplittable", missing: missingSections(live, rendered) };
|
|
162
|
+
}
|
|
163
|
+
if (freshHalves === undefined) {
|
|
164
|
+
// Floor-only rendered template: live bannered brief wants migrate, not merge.
|
|
137
165
|
return { kind: "unsplittable", missing: missingSections(live, rendered) };
|
|
138
166
|
}
|
|
139
167
|
|
|
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
168
|
if (PLACEHOLDER_PATTERN.test(freshHalves.shipped)) {
|
|
144
169
|
return { kind: "unrendered", missing: missingSections(live, rendered) };
|
|
145
170
|
}
|
|
@@ -148,20 +173,65 @@ export function checkBrief(live: string, rendered: string): BriefStatus {
|
|
|
148
173
|
|
|
149
174
|
return {
|
|
150
175
|
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
176
|
merged: freshHalves.shipped + liveHalves.owned,
|
|
155
177
|
liveShipped: liveHalves.shipped,
|
|
156
178
|
freshShipped: freshHalves.shipped,
|
|
157
179
|
};
|
|
158
180
|
}
|
|
159
181
|
|
|
182
|
+
/** Layout of brief files under a workspace root. */
|
|
183
|
+
export type BriefLayout =
|
|
184
|
+
| { kind: "overlay"; policyPath: string; orchestratorPath: string }
|
|
185
|
+
| { kind: "legacy-bannered"; orchestratorPath: string; owned: string }
|
|
186
|
+
| { kind: "legacy-handwritten"; orchestratorPath: string; missing: string[] }
|
|
187
|
+
| { kind: "missing" };
|
|
188
|
+
|
|
189
|
+
export function policyPathForRoot(workspaceRoot: string): string {
|
|
190
|
+
return join(workspaceRoot, POLICY_BRIEF_NAME);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function orchestratorPathForRoot(workspaceRoot: string): string {
|
|
194
|
+
return join(workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Classifies what sits in the workspace: overlay, migratable legacy, or absent.
|
|
199
|
+
*
|
|
200
|
+
* `floorHeadings` is the rendered floor (or composed) heading list used only to
|
|
201
|
+
* report missing sections for handwritten briefs.
|
|
202
|
+
*/
|
|
203
|
+
export function inspectBriefLayout(
|
|
204
|
+
workspaceRoot: string,
|
|
205
|
+
floorOrComposedForReport: string,
|
|
206
|
+
): BriefLayout {
|
|
207
|
+
const policyPath = policyPathForRoot(workspaceRoot);
|
|
208
|
+
const orchestratorPath = orchestratorPathForRoot(workspaceRoot);
|
|
209
|
+
if (existsSync(policyPath)) {
|
|
210
|
+
return { kind: "overlay", policyPath, orchestratorPath };
|
|
211
|
+
}
|
|
212
|
+
if (!existsSync(orchestratorPath)) return { kind: "missing" };
|
|
213
|
+
const live = readFileSync(orchestratorPath, "utf8");
|
|
214
|
+
const halves = splitBrief(live);
|
|
215
|
+
if (halves !== undefined) {
|
|
216
|
+
return { kind: "legacy-bannered", orchestratorPath, owned: halves.owned };
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
kind: "legacy-handwritten",
|
|
220
|
+
orchestratorPath,
|
|
221
|
+
missing: missingSections(live, floorOrComposedForReport),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Join rendered floor + live policy into the composed session brief. */
|
|
226
|
+
export function composeOrchestrator(floor: string, policy: string): string {
|
|
227
|
+
const f = floor.replace(/\s+$/, "\n");
|
|
228
|
+
const p = policy.replace(/^\s+/, "").replace(/\s+$/, "\n");
|
|
229
|
+
return `${f}\n${COMPOSE_BANNER}\n\n${p}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
160
232
|
/**
|
|
161
233
|
* 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.
|
|
234
|
+
* yes.
|
|
165
235
|
*/
|
|
166
236
|
export function shippedDiff(before: string, after: string): string {
|
|
167
237
|
const old = new Set(before.split("\n"));
|
|
@@ -176,22 +246,196 @@ export function shippedDiff(before: string, after: string): string {
|
|
|
176
246
|
return lines.join("\n");
|
|
177
247
|
}
|
|
178
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Writes content, leaving the previous file beside it when one existed.
|
|
251
|
+
*/
|
|
252
|
+
export function writeWithBackup(path: string, content: string): string | undefined {
|
|
253
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
254
|
+
let backup: string | undefined;
|
|
255
|
+
if (existsSync(path)) {
|
|
256
|
+
backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
257
|
+
writeFileSync(backup, readFileSync(path));
|
|
258
|
+
}
|
|
259
|
+
writeFileSync(path, content);
|
|
260
|
+
return backup;
|
|
261
|
+
}
|
|
262
|
+
|
|
179
263
|
/**
|
|
180
264
|
* Writes the merged brief, leaving the previous one beside it.
|
|
181
265
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* must never do is be the reason it is gone.
|
|
266
|
+
* @deprecated Prefer {@link migrateToPolicy} / overlay refresh. Kept for
|
|
267
|
+
* pre-overlay `--apply` on bannered single-file briefs.
|
|
185
268
|
*/
|
|
186
269
|
export function writeMergedBrief(path: string, merged: string): string {
|
|
187
|
-
const backup =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
270
|
+
const backup = writeWithBackup(path, merged);
|
|
271
|
+
return backup ?? `${path}.bak-missing`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Result of migrating a bannered ORCHESTRATOR.md into POLICY.md. */
|
|
275
|
+
export interface MigrateResult {
|
|
276
|
+
policyPath: string;
|
|
277
|
+
orchestratorPath: string;
|
|
278
|
+
policyBackup?: string;
|
|
279
|
+
orchestratorBackup?: string;
|
|
280
|
+
ownedBytes: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Lifts the owned half of a bannered orchestrator brief into `POLICY.md`, then
|
|
285
|
+
* writes a composed orchestrator from `floor` + that policy.
|
|
286
|
+
*/
|
|
287
|
+
export function migrateToPolicy(opts: {
|
|
288
|
+
orchestratorPath: string;
|
|
289
|
+
policyPath: string;
|
|
290
|
+
floor: string;
|
|
291
|
+
/** When set, use this owned text instead of splitting the live file. */
|
|
292
|
+
owned?: string;
|
|
293
|
+
}): MigrateResult {
|
|
294
|
+
const live = readFileSync(opts.orchestratorPath, "utf8");
|
|
295
|
+
const owned = opts.owned ?? splitBrief(live)?.owned;
|
|
296
|
+
if (owned === undefined) {
|
|
297
|
+
throw new Error(`cannot migrate ${opts.orchestratorPath}: no ${EDIT_BANNER} banner`);
|
|
298
|
+
}
|
|
299
|
+
const policyBody = owned.replace(/^\s+/, "");
|
|
300
|
+
const policyBackup = writeWithBackup(opts.policyPath, policyBody.endsWith("\n") ? policyBody : `${policyBody}\n`);
|
|
301
|
+
const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
|
|
302
|
+
const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed);
|
|
303
|
+
return {
|
|
304
|
+
policyPath: opts.policyPath,
|
|
305
|
+
orchestratorPath: opts.orchestratorPath,
|
|
306
|
+
policyBackup,
|
|
307
|
+
orchestratorBackup,
|
|
308
|
+
ownedBytes: policyBody.length,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Refresh composed ORCHESTRATOR.md from rendered floor + existing POLICY.md.
|
|
314
|
+
* Creates nothing when POLICY.md is absent (caller should migrate first).
|
|
315
|
+
*/
|
|
316
|
+
export function refreshComposedBrief(opts: {
|
|
317
|
+
orchestratorPath: string;
|
|
318
|
+
policyPath: string;
|
|
319
|
+
floor: string;
|
|
320
|
+
}): boolean {
|
|
321
|
+
if (!existsSync(opts.policyPath)) return false;
|
|
322
|
+
const policy = readFileSync(opts.policyPath, "utf8");
|
|
323
|
+
writeFileSync(opts.orchestratorPath, composeOrchestrator(opts.floor, policy));
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A proposed banner insertion for a hand-written brief (#20). */
|
|
328
|
+
export interface RetrofitProposal {
|
|
329
|
+
/** Byte offset in the live text where the banner block should be inserted. */
|
|
330
|
+
cut: number;
|
|
331
|
+
/** Heading that starts the owned half. */
|
|
332
|
+
atHeading: string;
|
|
333
|
+
/** Owned-topic headings (Releases / Project context / Reporting / Amendments). */
|
|
334
|
+
ownedHeadings: string[];
|
|
335
|
+
/** Non-owned headings that appear *before* the cut — stay on the floor side. */
|
|
336
|
+
floorAbove: string[];
|
|
337
|
+
/** Live text with the compose banner inserted at `cut`. */
|
|
338
|
+
retrofitted: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Why a retrofit cannot be applied automatically.
|
|
343
|
+
*
|
|
344
|
+
* `interleaved` means a floor-like heading (Duty, Learning loop, Hard boundaries,
|
|
345
|
+
* …) appears *below* the first owned-topic cut. Applying the banner there would
|
|
346
|
+
* push that floor section into POLICY.md on migrate — silent ownership theft.
|
|
347
|
+
*/
|
|
348
|
+
export type RetrofitRefusal = {
|
|
349
|
+
kind: "interleaved";
|
|
350
|
+
atHeading: string;
|
|
351
|
+
ownedHeadings: string[];
|
|
352
|
+
floorAbove: string[];
|
|
353
|
+
floorBelow: string[];
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
export type RetrofitResult =
|
|
357
|
+
| { kind: "ok"; proposal: RetrofitProposal }
|
|
358
|
+
| { kind: "no-cut" }
|
|
359
|
+
| RetrofitRefusal;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Propose inserting the YOURS TO EDIT banner before the first owned-topic
|
|
363
|
+
* heading.
|
|
364
|
+
*
|
|
365
|
+
* Headings are classified by **position relative to that cut**, not globally:
|
|
366
|
+
* floor-like headings above the cut stay above; any floor-like heading below
|
|
367
|
+
* the cut is a refuse — the operator must reorder or hand-classify before apply.
|
|
368
|
+
*/
|
|
369
|
+
export function proposeRetrofit(live: string): RetrofitResult {
|
|
370
|
+
const lines = live.split("\n");
|
|
371
|
+
const ownedHeadings: string[] = [];
|
|
372
|
+
const floorAbove: string[] = [];
|
|
373
|
+
const floorBelow: string[] = [];
|
|
374
|
+
let cutLine = -1;
|
|
375
|
+
let atHeading: string | undefined;
|
|
376
|
+
for (let i = 0; i < lines.length; i++) {
|
|
377
|
+
const line = lines[i];
|
|
378
|
+
if (line === undefined || !line.startsWith("## ")) continue;
|
|
379
|
+
const heading = line.slice(3).trim();
|
|
380
|
+
if (isOwnedTopic(heading)) {
|
|
381
|
+
ownedHeadings.push(heading);
|
|
382
|
+
if (cutLine < 0) {
|
|
383
|
+
cutLine = i;
|
|
384
|
+
atHeading = heading;
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// Position relative to the (eventual) cut — headings before any owned topic
|
|
389
|
+
// are tentatively "above"; once the cut is known, later floor headings are
|
|
390
|
+
// "below" and block apply.
|
|
391
|
+
if (cutLine < 0) floorAbove.push(heading);
|
|
392
|
+
else floorBelow.push(heading);
|
|
393
|
+
}
|
|
394
|
+
if (cutLine < 0 || atHeading === undefined) return { kind: "no-cut" };
|
|
395
|
+
|
|
396
|
+
if (floorBelow.length > 0) {
|
|
397
|
+
return {
|
|
398
|
+
kind: "interleaved",
|
|
399
|
+
atHeading,
|
|
400
|
+
ownedHeadings,
|
|
401
|
+
floorAbove,
|
|
402
|
+
floorBelow,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Byte offset: sum of prior lines + newlines.
|
|
407
|
+
let cut = 0;
|
|
408
|
+
for (let i = 0; i < cutLine; i++) {
|
|
409
|
+
const line = lines[i];
|
|
410
|
+
cut += (line?.length ?? 0) + 1;
|
|
411
|
+
}
|
|
412
|
+
const retrofitted = `${live.slice(0, cut)}${COMPOSE_BANNER}\n\n${live.slice(cut)}`;
|
|
413
|
+
return {
|
|
414
|
+
kind: "ok",
|
|
415
|
+
proposal: { cut, atHeading, ownedHeadings, floorAbove, retrofitted },
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Insert the banner into a hand-written brief (with backup). */
|
|
420
|
+
export function applyRetrofit(path: string, proposal: RetrofitProposal): string {
|
|
421
|
+
const backup = writeWithBackup(path, proposal.retrofitted);
|
|
422
|
+
return backup ?? `${path}.bak-missing`;
|
|
191
423
|
}
|
|
192
424
|
|
|
193
425
|
/** The check rendered for a terminal, including what to do next. */
|
|
194
426
|
export function formatBriefStatus(path: string, status: BriefStatus): string {
|
|
427
|
+
if (status.kind === "overlay") {
|
|
428
|
+
return [
|
|
429
|
+
`brief overlay active`,
|
|
430
|
+
"",
|
|
431
|
+
` floor package template → recomposed into ${status.orchestratorPath} each tick`,
|
|
432
|
+
` policy ${status.policyPath} (Learning loop / operator edits)`,
|
|
433
|
+
"",
|
|
434
|
+
"Protocol updates: npm install omp-conductor@… and restart — no brief-upgrade --apply.",
|
|
435
|
+
"Legacy migrate: omp-conductor brief-upgrade --migrate",
|
|
436
|
+
].join("\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
195
439
|
if (status.kind === "current") {
|
|
196
440
|
return [`brief ${path}`, "", "up to date — its shipped half matches this version of the template."].join("\n");
|
|
197
441
|
}
|
|
@@ -200,13 +444,13 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
|
|
|
200
444
|
return [
|
|
201
445
|
`brief ${path}`,
|
|
202
446
|
"",
|
|
203
|
-
"
|
|
204
|
-
"banner.
|
|
205
|
-
"unchanged.",
|
|
447
|
+
"Legacy single-file brief: this version ships a different half above the YOURS TO EDIT",
|
|
448
|
+
"banner. Prefer migrating to the POLICY.md overlay:",
|
|
206
449
|
"",
|
|
207
450
|
shippedDiff(status.liveShipped, status.freshShipped),
|
|
208
451
|
"",
|
|
209
|
-
"
|
|
452
|
+
"Migrate: omp-conductor brief-upgrade --migrate",
|
|
453
|
+
"Or apply the old single-file merge: omp-conductor brief-upgrade --apply",
|
|
210
454
|
"The previous file is kept beside it as ORCHESTRATOR.md.bak-<timestamp>.",
|
|
211
455
|
].join("\n");
|
|
212
456
|
}
|
|
@@ -224,6 +468,11 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
|
|
|
224
468
|
"This brief has no YOURS TO EDIT banner, so it was written by hand or predates",
|
|
225
469
|
"the template split. There is no way to tell which lines are yours, so nothing",
|
|
226
470
|
"will be rewritten automatically.",
|
|
471
|
+
"",
|
|
472
|
+
"Retrofit a banner at the first Releases/Project context/Reporting/Amendments",
|
|
473
|
+
"heading, then migrate:",
|
|
474
|
+
" omp-conductor brief-upgrade --retrofit",
|
|
475
|
+
" omp-conductor brief-upgrade --migrate",
|
|
227
476
|
]),
|
|
228
477
|
);
|
|
229
478
|
if (status.missing.length === 0) {
|
|
@@ -240,3 +489,57 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
|
|
|
240
489
|
);
|
|
241
490
|
return lines.join("\n");
|
|
242
491
|
}
|
|
492
|
+
|
|
493
|
+
export function formatRetrofitProposal(path: string, proposal: RetrofitProposal): string {
|
|
494
|
+
return [
|
|
495
|
+
`retrofit ${path}`,
|
|
496
|
+
"",
|
|
497
|
+
`Insert the YOURS TO EDIT banner before ## ${proposal.atHeading}.`,
|
|
498
|
+
"",
|
|
499
|
+
`Owned-topic headings (${proposal.ownedHeadings.length}):`,
|
|
500
|
+
...proposal.ownedHeadings.map((h) => ` - ${h}`),
|
|
501
|
+
`Floor-like headings above the banner (${proposal.floorAbove.length}):`,
|
|
502
|
+
...(proposal.floorAbove.length === 0
|
|
503
|
+
? [" (none)"]
|
|
504
|
+
: proposal.floorAbove.map((h) => ` - ${h}`)),
|
|
505
|
+
"",
|
|
506
|
+
"Apply: omp-conductor brief-upgrade --retrofit --apply",
|
|
507
|
+
"Then: omp-conductor brief-upgrade --migrate",
|
|
508
|
+
].join("\n");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function formatRetrofitRefusal(path: string, refusal: RetrofitRefusal): string {
|
|
512
|
+
return [
|
|
513
|
+
`retrofit ${path}`,
|
|
514
|
+
"",
|
|
515
|
+
`Refused: floor-like heading(s) appear below the proposed cut at ## ${refusal.atHeading}.`,
|
|
516
|
+
"Applying the banner here would put those sections into POLICY.md on migrate.",
|
|
517
|
+
"",
|
|
518
|
+
`Owned-topic headings (${refusal.ownedHeadings.length}):`,
|
|
519
|
+
...refusal.ownedHeadings.map((h) => ` - ${h}`),
|
|
520
|
+
`Floor-like headings above the cut (${refusal.floorAbove.length}):`,
|
|
521
|
+
...(refusal.floorAbove.length === 0
|
|
522
|
+
? [" (none)"]
|
|
523
|
+
: refusal.floorAbove.map((h) => ` - ${h}`)),
|
|
524
|
+
`Floor-like headings BELOW the cut — must move or reclassify (${refusal.floorBelow.length}):`,
|
|
525
|
+
...refusal.floorBelow.map((h) => ` - ${h}`),
|
|
526
|
+
"",
|
|
527
|
+
"Reorder so all Duties / Hard boundaries / Learning loop sit above Releases,",
|
|
528
|
+
"or hand-insert the YOURS TO EDIT banner at the line you intend, then migrate.",
|
|
529
|
+
"Nothing was written.",
|
|
530
|
+
].join("\n");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function formatMigrateResult(result: MigrateResult): string {
|
|
534
|
+
return [
|
|
535
|
+
"migrated to POLICY.md overlay",
|
|
536
|
+
"",
|
|
537
|
+
` policy ${result.policyPath} (${result.ownedBytes} bytes)`,
|
|
538
|
+
...(result.policyBackup ? [` policy bak ${result.policyBackup}`] : []),
|
|
539
|
+
` composed ${result.orchestratorPath}`,
|
|
540
|
+
...(result.orchestratorBackup ? [` brief bak ${result.orchestratorBackup}`] : []),
|
|
541
|
+
"",
|
|
542
|
+
"Next ticks recompose ORCHESTRATOR.md from the package floor + POLICY.md.",
|
|
543
|
+
"Edit only POLICY.md going forward.",
|
|
544
|
+
].join("\n");
|
|
545
|
+
}
|