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/src/plugin.ts CHANGED
@@ -11,8 +11,16 @@
11
11
  * worth protecting is on `setup()` below — nothing is written before the confirm.
12
12
  */
13
13
  import { existsSync, readFileSync } from "node:fs";
14
- import { isAbsolute } from "node:path";
15
- import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
14
+ import { dirname, isAbsolute } from "node:path";
15
+ import {
16
+ checkBrief,
17
+ formatBriefStatus,
18
+ formatMigrateResult,
19
+ inspectBriefLayout,
20
+ migrateToPolicy,
21
+ repairPolicyBannerCrumbs,
22
+ writeMergedBrief,
23
+ } from "./brief-upgrade.ts";
16
24
  import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
17
25
  import {
18
26
  armConductor,
@@ -27,11 +35,14 @@ import { defaultGraphRoot } from "./graph.ts";
27
35
  import {
28
36
  AMEND_AREAS,
29
37
  ORCHESTRATOR_BRIEF_NAME,
38
+ POLICY_BRIEF_NAME,
30
39
  REPORT_SCOPE_CHOICES,
31
40
  SETUP_DEFAULTS,
32
41
  amendChoices,
33
42
  answersFromProject,
34
43
  briefPathForProject,
44
+ policyPathForProject,
45
+ renderFloorForProject,
35
46
  buildConfig,
36
47
  checkTokenScopes,
37
48
  createMissingLabels,
@@ -374,17 +385,17 @@ async function askGraphRoot(
374
385
  async function askOrchestratorBrief(ctx: CommandContext, a: SetupAnswers): Promise<boolean> {
375
386
  const path = orchestratorBriefPath(a);
376
387
  const wanted = await ctx.ui.confirm(
377
- `Write an orchestrator brief template to ${path}?`,
378
- `It is the standing prompt for your supervising session: duties, tiers, and boundaries, ` +
379
- `plus a release policy and a reporting section that are yours to edit. ` +
380
- `The conductor never reads it back — it stops at green PRs either way.`,
388
+ `Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`,
389
+ `Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` +
390
+ `(Releases, Project context, Reporting, Amendments yours to edit via the Learning loop). ` +
391
+ `The conductor stops at green PRs either way.`,
381
392
  );
382
393
  if (!wanted) return false;
383
394
  if (!existsSync(path)) return true;
384
395
 
385
396
  return await ctx.ui.confirm(
386
- `Overwrite the existing ${ORCHESTRATOR_BRIEF_NAME}?`,
387
- `${path} already exists. Overwriting replaces it with the shipped template — any policy you wrote there is lost.`,
397
+ `Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`,
398
+ `${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`,
388
399
  );
389
400
  }
390
401
 
@@ -885,7 +896,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
885
896
  `Wrote ${path} and armed the conductor.`,
886
897
  briefPath === undefined
887
898
  ? "No orchestrator brief written — the conductor stops at green PRs; merges and releases stay human."
888
- : `Wrote ${briefPath} — edit its "Releases" and "Reporting" sections; nothing here reads them back.`,
899
+ : `Wrote ${briefPath} + POLICY.md — edit POLICY.md (Releases/Reporting); floor refreshes each tick.`,
889
900
  "",
890
901
  "Dry run against the config just written:",
891
902
  ...(await tryPreview(answers.projectName)),
@@ -929,18 +940,68 @@ export default function conductorPlugin(pi: PluginApi): void {
929
940
  case "brief-upgrade": {
930
941
  const p = findProject(loadConfig(), project);
931
942
  const path = briefPathForProject(p);
932
- if (!existsSync(path)) {
943
+ const rendered = renderBriefForProject(p);
944
+ const layout = inspectBriefLayout(p.workspaceRoot, rendered);
945
+ if (layout.kind === "missing") {
946
+ ctx.ui.notify(
947
+ `No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME}.`,
948
+ "warning",
949
+ );
950
+ break;
951
+ }
952
+ if (layout.kind === "overlay") {
953
+ ctx.ui.notify(
954
+ formatBriefStatus(path, {
955
+ kind: "overlay",
956
+ policyPath: layout.policyPath,
957
+ orchestratorPath: layout.orchestratorPath,
958
+ }),
959
+ "info",
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
+ }
978
+ break;
979
+ }
980
+ if (layout.kind === "legacy-bannered") {
933
981
  ctx.ui.notify(
934
- `No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME}.`,
982
+ [
983
+ `Legacy bannered brief at ${layout.orchestratorPath}.`,
984
+ "Migrate the owned half into POLICY.md so the package floor refreshes each tick.",
985
+ ].join("\n"),
935
986
  "warning",
936
987
  );
988
+ const migrate = await ctx.ui.confirm(
989
+ "Migrate to POLICY.md overlay?",
990
+ "Writes POLICY.md from everything below YOURS TO EDIT, recomposes ORCHESTRATOR.md from the package floor + that policy, and keeps backups.",
991
+ );
992
+ if (migrate) {
993
+ const result = migrateToPolicy({
994
+ orchestratorPath: layout.orchestratorPath,
995
+ policyPath: policyPathForProject(p),
996
+ floor: renderFloorForProject(p),
997
+ owned: layout.owned,
998
+ });
999
+ ctx.ui.notify(formatMigrateResult(result), "info");
1000
+ }
937
1001
  break;
938
1002
  }
939
- const status = checkBrief(readFileSync(path, "utf8"), renderBriefForProject(p));
940
- ctx.ui.notify(formatBriefStatus(path, status), status.kind === "current" ? "info" : "warning");
941
- // Confirmed here rather than applied on sight: this file is a standing
942
- // prompt the operator may have spent an hour on, so the diff they just
943
- // read is the thing they are agreeing to.
1003
+ const status = checkBrief(readFileSync(path, "utf8"), rendered);
1004
+ ctx.ui.notify(formatBriefStatus(path, status), "warning");
944
1005
  if (status.kind === "mergeable") {
945
1006
  const apply = await ctx.ui.confirm(
946
1007
  "Upgrade the brief?",
package/src/setup.ts CHANGED
@@ -23,6 +23,15 @@
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
25
  import { dirname, join } from "node:path";
26
+ import {
27
+ COMPOSE_BANNER,
28
+ ORCHESTRATOR_BRIEF_NAME,
29
+ POLICY_BRIEF_NAME,
30
+ composeOrchestrator,
31
+ policyPathForRoot,
32
+ renderBriefTemplate,
33
+ writeWithBackup,
34
+ } from "./brief-upgrade.ts";
26
35
  import { configPath, resolveCaps, stateDir } from "./config.ts";
27
36
  import { graphProjectPath, graphRepos } from "./graph.ts";
28
37
  import {
@@ -37,7 +46,6 @@ import {
37
46
  type ReportScope,
38
47
  type RepoTarget,
39
48
  } from "./types.ts";
40
- import { renderBrief } from "./worker.ts";
41
49
 
42
50
  /**
43
51
  * Every decision the wizard needs, in one plain object. Collected by the UI,
@@ -197,12 +205,14 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
197
205
  " one is a hard boundary, not a preference.",
198
206
  };
199
207
 
200
- /** The operator's own brief, rendered into the project's workspace root. */
201
- export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
208
+ export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
202
209
 
203
- /** Shipped in `files[]`, so this resolves in an installed package too. */
210
+ /** Shipped floor template duties, tiers, hard boundaries, Learning loop. */
204
211
  const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md");
205
212
 
213
+ /** Shipped POLICY.md scaffold — Releases, Project context, Reporting, Amendments. */
214
+ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
215
+
206
216
  /**
207
217
  * `repo` writes labels and closes issues; `project` moves cards on the board.
208
218
  * Both are load-bearing for an unattended loop, so a missing one is reported
@@ -572,46 +582,69 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
572
582
  }
573
583
 
574
584
  /**
575
- * Where a configured project's brief lives: beside its worktrees, under the state
576
- * directory, so it is on the same disk the fleet already owns and survives a
577
- * reinstall of the package. Derived from the project rather than fixed, so a
578
- * project that ever gains a chosen workspace root keeps its brief with it.
585
+ * Where a configured project's composed brief lives: beside its worktrees, under
586
+ * the state directory, so it is on the same disk the fleet already owns and
587
+ * survives a reinstall of the package. Derived from the project rather than
588
+ * fixed, so a project that ever gains a chosen workspace root keeps its brief
589
+ * with it.
579
590
  */
580
591
  export function briefPathForProject(p: ProjectConfig): string {
581
592
  return join(p.workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
582
593
  }
583
594
 
584
- /**
585
- * The brief template exactly as shipped, placeholders and all.
586
- *
587
- * Exported for the upgrade check, which has to be able to read the shipped text
588
- * on a host that has no config to render it against.
589
- */
590
- export function shippedBriefTemplate(): string {
591
- return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8");
595
+ /** Where the fleet-owned POLICY.md overlay lives for a project. */
596
+ export function policyPathForProject(p: ProjectConfig): string {
597
+ return policyPathForRoot(p.workspaceRoot);
592
598
  }
593
599
 
594
- /**
595
- * The shipped template with a configured project's real values in it.
596
- *
597
- * Only the coordinates, the chosen scope and the authority paragraph are
598
- * substituted: the rest of the policy text is left exactly as shipped, because
599
- * from here on the file is the operator's to edit and nothing in this package
600
- * reads it back.
601
- *
602
- * Takes a `ProjectConfig` rather than answers so that a *later* upgrade check can
603
- * reproduce the same render from what is on disk, months after the wizard's
604
- * answers are gone.
605
- */
606
- export function renderBriefForProject(p: ProjectConfig): string {
607
- return renderBrief(readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8"), {
600
+ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
601
+ return {
608
602
  PROJECT: p.name,
609
603
  TRACKER_REPO: p.tracker.repo,
610
604
  QUEUE_LABEL: p.queueLabel,
611
605
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
612
606
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
613
607
  REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
614
- });
608
+ };
609
+ }
610
+
611
+ /** Package floor template, placeholders and all. */
612
+ export function shippedFloorTemplate(): string {
613
+ return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8");
614
+ }
615
+
616
+ /** POLICY.md scaffold template, placeholders and all. */
617
+ export function shippedPolicyTemplate(): string {
618
+ return readFileSync(POLICY_TEMPLATE_PATH, "utf8");
619
+ }
620
+
621
+ /**
622
+ * Floor + policy templates concatenated with the compose banner, placeholders
623
+ * intact. Used when no project config is available to render coordinates.
624
+ */
625
+ export function shippedBriefTemplate(): string {
626
+ return composeOrchestrator(shippedFloorTemplate(), shippedPolicyTemplate());
627
+ }
628
+
629
+ /** Rendered package floor for a configured project. */
630
+ export function renderFloorForProject(p: ProjectConfig): string {
631
+ return renderBriefTemplate(shippedFloorTemplate(), briefVarsForProject(p));
632
+ }
633
+
634
+ /** Rendered POLICY.md scaffold for a configured project. */
635
+ export function renderPolicyForProject(p: ProjectConfig): string {
636
+ return renderBriefTemplate(shippedPolicyTemplate(), briefVarsForProject(p));
637
+ }
638
+
639
+ /**
640
+ * Composed session brief: rendered floor + POLICY scaffold (or a caller's policy).
641
+ *
642
+ * Setup writes the policy half to `POLICY.md` and this compose to
643
+ * `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md so package
644
+ * floor updates apply without brief-upgrade.
645
+ */
646
+ export function renderBriefForProject(p: ProjectConfig, policyText?: string): string {
647
+ return composeOrchestrator(renderFloorForProject(p), policyText ?? renderPolicyForProject(p));
615
648
  }
616
649
 
617
650
  /** Wizard-time path, via the project the answers describe. */
@@ -625,7 +658,7 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
625
658
  }
626
659
 
627
660
  /**
628
- * Writes the rendered brief and returns where it went.
661
+ * Writes `POLICY.md` + composed `ORCHESTRATOR.md`, and returns the composed path.
629
662
  *
630
663
  * Unconditional by design: the "do not clobber my edits" decision belongs to the
631
664
  * operator, is asked in the wizard, and arrives here as
@@ -634,10 +667,39 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
634
667
  * must get an overwrite.
635
668
  */
636
669
  export function writeOrchestratorBrief(a: SetupAnswers): string {
637
- const path = orchestratorBriefPath(a);
638
- mkdirSync(dirname(path), { recursive: true });
639
- writeFileSync(path, renderOrchestratorBrief(a));
640
- return path;
670
+ const project = buildProject(a);
671
+ const policyPath = policyPathForProject(project);
672
+ const orchestratorPath = briefPathForProject(project);
673
+ mkdirSync(dirname(orchestratorPath), { recursive: true });
674
+ const policy = renderPolicyForProject(project);
675
+ writeFileSync(policyPath, policy);
676
+ writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
677
+ return orchestratorPath;
678
+ }
679
+
680
+ /**
681
+ * Recompose `ORCHESTRATOR.md` from the package floor + live `POLICY.md`.
682
+ *
683
+ * Returns false when POLICY.md is missing (caller should migrate or set up).
684
+ */
685
+ export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
686
+ const policyPath = policyPathForProject(p);
687
+ if (!existsSync(policyPath)) return false;
688
+ const orchestratorPath = briefPathForProject(p);
689
+ mkdirSync(dirname(orchestratorPath), { recursive: true });
690
+ writeFileSync(
691
+ orchestratorPath,
692
+ composeOrchestrator(renderFloorForProject(p), readFileSync(policyPath, "utf8")),
693
+ );
694
+ return true;
695
+ }
696
+
697
+ /** @internal test helper — expose compose banner for assertions. */
698
+ export const BRIEF_COMPOSE_BANNER = COMPOSE_BANNER;
699
+
700
+ /** Backup-aware POLICY write used by migrate paths that already computed text. */
701
+ export function writePolicyFile(path: string, content: string): string | undefined {
702
+ return writeWithBackup(path, content);
641
703
  }
642
704
 
643
705
  /**
@@ -861,11 +923,12 @@ export function summarisePlan(
861
923
  ` scope ${a.reportScope} — ${chosen?.description ?? "unknown scope"}`,
862
924
  );
863
925
  if (a.writeOrchestratorBrief) {
926
+ const policyPath = briefPath.replace(/ORCHESTRATOR\.md$/, "POLICY.md");
864
927
  lines.push(
865
- existsSync(briefPath)
866
- ? ` brief would OVERWRITE ${briefPath}`
867
- : ` brief would write ${briefPath}`,
868
- " yours to edit afterwards release policy lives there, not in this package",
928
+ existsSync(briefPath) || existsSync(policyPath)
929
+ ? ` brief would OVERWRITE ${briefPath} + POLICY.md`
930
+ : ` brief would write ${briefPath} + POLICY.md`,
931
+ " POLICY.md is yoursReleases/Reporting/Amendments; floor recomposes each tick",
869
932
  );
870
933
  } else {
871
934
  lines.push(
@@ -1002,7 +1065,7 @@ export const AMEND_AREAS: {
1002
1065
  },
1003
1066
  brief: {
1004
1067
  name: "orchestrator brief",
1005
- asks: `whether to render ${ORCHESTRATOR_BRIEF_NAME} — the one area that writes no config key`,
1068
+ asks: `whether to write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} — the one area that writes no config key`,
1006
1069
  describe: (p) => {
1007
1070
  const path = briefPathForProject(p);
1008
1071
  return existsSync(path) ? `written at ${path}` : `none at ${path}`;