omp-conductor 0.14.0 → 0.15.0

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/setup.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The onboarding core behind `/conductor setup`.
2
+ * The onboarding core behind `omp-conductor setup`.
3
3
  *
4
4
  * Everything here is headless and synchronously testable: the plugin owns the
5
5
  * dialogs, this module owns the decisions. That split is the point — a wizard
@@ -72,6 +72,53 @@ import {
72
72
  type ResolvedGrants,
73
73
  } from "./types.ts";
74
74
 
75
+ /**
76
+ * What the onboarding skill interviewed for and no probe can supply.
77
+ *
78
+ * The skill is explicit that reading a repo answers none of this: "a tracker
79
+ * shows what is open, never what matters" (`SKILL.md:337-341`), and a release
80
+ * boundary is a sentence only the operator can say. Every field here fills a
81
+ * stub the shipped `policy.md` already carries, which is what makes deleting the
82
+ * skill (#309) a move rather than a loss.
83
+ *
84
+ * Every field is optional because the object is what has been *collected so
85
+ * far*: an amend that never reaches these prompts carries none of them, and
86
+ * `judgment` itself is absent on a project re-read from disk, since none of this
87
+ * lives in `config.json`.
88
+ *
89
+ * `boundary` and everything under it is asked only when the orchestrator holds
90
+ * some release shape: a humans-release fleet has no boundary to draw, and asking
91
+ * for one would invite a procedure the config does not grant.
92
+ */
93
+ export interface OperatorJudgment {
94
+ /** Where the roadmap lives and what is being shipped now. Always *asked*;
95
+ * absent means the interview has not reached it (or the operator left it blank). */
96
+ roadmap?: string;
97
+ /** Where the orchestrator's leg ends, in one sentence. */
98
+ boundary?: string;
99
+ /** What may be released, and from which branch. */
100
+ releaseWhat?: string;
101
+ /** Batched how, after which *named* checks are green. */
102
+ releaseWhen?: string;
103
+ /** The proof that must be held first — results read, not an impression. */
104
+ releaseProof?: string;
105
+ /** What must still be asked, every time. */
106
+ releaseAsk?: string;
107
+ /** What stays permanently forbidden. */
108
+ releaseForbidden?: string;
109
+ /** The batching unit in the operator's own vocabulary. */
110
+ worthCutting?: string;
111
+ /** Who owns the rollback. A named person moves the boundary earlier. */
112
+ rollbackOwner?: string;
113
+ /**
114
+ * Whether the operator accepted the boundary-moves-earlier consequence of
115
+ * naming a person as the rollback owner. Recorded rather than enforced: the
116
+ * wizard states the consequence and offers to move the boundary, and an
117
+ * operator who declines has made a choice, not a mistake.
118
+ */
119
+ rollbackMovesBoundary?: boolean;
120
+ }
121
+
75
122
  /**
76
123
  * Every decision the wizard needs, in one plain object. Collected by the UI,
77
124
  * consumed by `buildConfig`, so the prompt order can change without touching
@@ -93,6 +140,8 @@ export interface SetupAnswers {
93
140
  */
94
141
  workerModel?: string;
95
142
  telegramChatId?: string;
143
+ /** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
144
+ telegramTopicId?: number;
96
145
  fallbackToIssueComment: boolean;
97
146
  /** How loud the supervising orchestrator session should be. */
98
147
  reportScope: ReportScopeChoice;
@@ -128,6 +177,17 @@ export interface SetupAnswers {
128
177
  * prompt that asked it to the step that acts on it.
129
178
  */
130
179
  writeOrchestratorBrief: boolean;
180
+ /**
181
+ * The operator's own judgment, asked in the interview because no amount of
182
+ * repo reading produces it — and because `src/briefs/policy.md` ships stubs
183
+ * waiting for exactly these words (`:25-28`, `:53-56`).
184
+ *
185
+ * Not config: none of it is a key the daemon reads. It exists to reach
186
+ * `POLICY.md` — written directly, and offered to a prose probe as stated input
187
+ * the probe may not invent. It rides on the answers object because the wizard
188
+ * has to carry it from the prompt that asked it to the step that writes it.
189
+ */
190
+ judgment?: OperatorJudgment;
131
191
  /**
132
192
  * Who lands green PRs and who cuts releases. Both default to the human — see
133
193
  * {@link DEFAULT_AUTHORITY} — and the answer is what the rendered brief's
@@ -153,6 +213,16 @@ export interface SetupAnswers {
153
213
  * the same question four times to arrive at four siblings.
154
214
  */
155
215
  graphRoot?: string;
216
+ /**
217
+ * Where this project's worktrees live. Absent means {@link defaultWorkspaceRoot}
218
+ * under the state dir (the first-project layout). An added project seeds
219
+ * `~/.omp/conductor/projects/<name>/worktrees` instead; an amend carries the
220
+ * path already on disk so a re-run never migrates a neighbour.
221
+ */
222
+ workspaceRoot?: string;
223
+ /** Bare mirrors for this project; same seeding rules as {@link workspaceRoot}. */
224
+ mirrorRoot?: string;
225
+
156
226
  }
157
227
 
158
228
  /** What `gh auth status` says the active token may do. */
@@ -642,6 +712,13 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
642
712
  if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
643
713
  escalation.telegramChatId = a.telegramChatId.trim();
644
714
  }
715
+ if (
716
+ a.telegramTopicId !== undefined &&
717
+ Number.isFinite(a.telegramTopicId) &&
718
+ Number.isSafeInteger(a.telegramTopicId)
719
+ ) {
720
+ escalation.telegramTopicId = a.telegramTopicId;
721
+ }
645
722
 
646
723
  const caps: Partial<Caps> = {};
647
724
  // Drops `undefined` members so an unanswered cap is absent from the JSON
@@ -679,8 +756,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
679
756
  reporting: reportingFromAnswers(a),
680
757
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
681
758
  // uninstall, and neither can land in a repo the daemon then tries to commit.
682
- workspaceRoot: defaultWorkspaceRoot(),
683
- mirrorRoot: defaultMirrorRoot(),
759
+ // An explicit answer wins (added projects seed under projects/<name>/; amends
760
+ // carry the path already on disk). Absence keeps the first-project flat layout.
761
+ workspaceRoot: a.workspaceRoot?.trim() || defaultWorkspaceRoot(),
762
+ mirrorRoot: a.mirrorRoot?.trim() || defaultMirrorRoot(),
684
763
  };
685
764
  }
686
765
 
@@ -695,6 +774,16 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
695
774
  export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): ConductorConfig {
696
775
  const project = buildProject(a);
697
776
  const previous = existing?.projects ?? [];
777
+ const collision = previous.find(
778
+ (p) => p.name !== project.name && p.workspaceRoot === project.workspaceRoot,
779
+ );
780
+ if (collision !== undefined) {
781
+ throw new Error(
782
+ `workspaceRoot ${project.workspaceRoot} collides between projects ` +
783
+ `"${collision.name}" and "${project.name}" — two fleet sessions cannot share a cwd ` +
784
+ `(one tick config would clobber the other). Pick a different workspaceRoot for one of them.`,
785
+ );
786
+ }
698
787
 
699
788
  return {
700
789
  version: CONFIG_VERSION,
@@ -719,7 +808,14 @@ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): Conduc
719
808
  * default at each prompt, which is how one prompt comes to disagree with the
720
809
  * config key it writes.
721
810
  */
722
- export function defaultAnswers(projectName: string): SetupAnswers {
811
+ export function defaultAnswers(projectName: string, opts: { added?: boolean } = {}): SetupAnswers {
812
+ // Added projects live under projects/<name>/ so two fleets never share a cwd
813
+ // with the first project's flat worktrees/mirrors. The first project keeps
814
+ // those flat paths; buildProject falls back to defaultWorkspaceRoot() when
815
+ // these are absent.
816
+ const scopedBase = opts.added === true && projectName.trim().length > 0
817
+ ? join(stateDir(), "projects", projectName.trim())
818
+ : undefined;
723
819
  return {
724
820
  projectName,
725
821
  // Empty rather than a plausible guess: both are required, and a pre-filled
@@ -737,6 +833,12 @@ export function defaultAnswers(projectName: string): SetupAnswers {
737
833
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
738
834
  reportScope: SETUP_DEFAULT_REPORT_SCOPE,
739
835
  writeOrchestratorBrief: false,
836
+ ...(scopedBase === undefined
837
+ ? {}
838
+ : {
839
+ workspaceRoot: defaultWorkspaceRoot(scopedBase),
840
+ mirrorRoot: defaultMirrorRoot(scopedBase),
841
+ }),
740
842
  };
741
843
  }
742
844
 
@@ -835,6 +937,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
835
937
  // what keeps the rewritten config identical to the one that was read.
836
938
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
837
939
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
940
+ if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
838
941
  if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
839
942
  if (p.reporting?.digest.timezone !== undefined) {
840
943
  answers.reportingTimezone = p.reporting.digest.timezone;
@@ -850,6 +953,9 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
850
953
  }
851
954
  const graphed = graphRepos(p)[0];
852
955
  if (graphed !== undefined) answers.graphRoot = dirname(graphed.graphProject);
956
+ if (p.workspaceRoot.length > 0) answers.workspaceRoot = p.workspaceRoot;
957
+ if (p.mirrorRoot.length > 0) answers.mirrorRoot = p.mirrorRoot;
958
+
853
959
 
854
960
  return answers;
855
961
  }
@@ -894,7 +1000,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
894
1000
  function policySourceLine(p: ProjectConfig): string {
895
1001
  return (
896
1002
  `They are in \`${configPath()}\` under \`projects[] "${p.name}" policy\`, and they change with ` +
897
- `\`/conductor setup\` → "${AMEND_AREAS.policy.name}". Never by hand in this file.`
1003
+ `\`omp-conductor setup policy\` ("${AMEND_AREAS.policy.name}"). Never by hand in this file.`
898
1004
  );
899
1005
  }
900
1006
 
@@ -943,8 +1049,48 @@ export function orchestratorBriefPath(a: SetupAnswers): string {
943
1049
  }
944
1050
 
945
1051
  /** Wizard-time render, via the project the answers describe. */
946
- export function renderOrchestratorBrief(a: SetupAnswers): string {
947
- return renderBriefForProject(buildProject(a));
1052
+ export function renderOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}): string {
1053
+ // Rendered through the same judgment fill the writer applies, so "what setup
1054
+ // would write" and "what setup writes" cannot diverge — the preview an operator
1055
+ // consents to is the file they get.
1056
+ const project = buildProject(a);
1057
+ return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose));
1058
+ }
1059
+
1060
+ /** `POLICY.md` for these answers: the project's own template plus the interview. */
1061
+ export function renderPolicy(a: SetupAnswers, prose: ProbedProse = {}): string {
1062
+ return fillJudgment(renderPolicyForProject(buildProject(a)), a.judgment, prose);
1063
+ }
1064
+
1065
+ /**
1066
+ * The body of `## Project context`.
1067
+ *
1068
+ * **One writer, not two.** Confirmed prose is the whole section: the probe was
1069
+ * handed the roadmap answer as a stated input and told to carry it as its fourth
1070
+ * item, so synthesising a roadmap line here as well would print the operator's
1071
+ * priority twice — once out of the product/repos/grooming order the section is
1072
+ * written to be read in.
1073
+ *
1074
+ * Without prose — `--no-ai`, a declined preview, an unreachable peer — the roadmap
1075
+ * answer is still the one part of this section the interview *did* collect, so it
1076
+ * is written above a note naming what is left.
1077
+ */
1078
+ function contextSection(roadmap: string | undefined, context: string | undefined): string {
1079
+ const written = context?.trim();
1080
+ if (written !== undefined && written.length > 0) {
1081
+ // The heading came from the template; a probe that repeated it would leave two.
1082
+ return written.replace(/^##\s*Project context[^\n]*\n+/, "");
1083
+ }
1084
+
1085
+ const stated = roadmap?.trim();
1086
+ return [
1087
+ ...(stated === undefined || stated.length === 0
1088
+ ? []
1089
+ : [`**Roadmap and current priority.** ${stated}`, "", "Judge grooming priority against that, not against recency.", ""]),
1090
+ "Still to fill in, by hand or by re-running `omp-conductor setup brief`: the",
1091
+ "product in a paragraph, a map of which repo owns what, and the grooming guidance",
1092
+ "Duty 2 needs to spot issues that would collide.",
1093
+ ].join("\n");
948
1094
  }
949
1095
 
950
1096
  /**
@@ -956,17 +1102,109 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
956
1102
  * that dialog's answer un-actionable — an operator who says "yes, overwrite it"
957
1103
  * must get an overwrite.
958
1104
  */
959
- export function writeOrchestratorBrief(a: SetupAnswers): string {
1105
+ export function writeOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}): string {
960
1106
  const project = buildProject(a);
961
1107
  const policyPath = policyPathForProject(project);
962
1108
  const orchestratorPath = briefPathForProject(project);
963
1109
  mkdirSync(dirname(orchestratorPath), { recursive: true });
964
- const policy = renderPolicyForProject(project);
1110
+ const policy = renderPolicy(a, prose);
965
1111
  writeFileSync(policyPath, policy);
966
1112
  writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
967
1113
  return orchestratorPath;
968
1114
  }
969
1115
 
1116
+ /** The stub `policy.md` ships where the operator's own priority pointer belongs. */
1117
+ const CONTEXT_STUB =
1118
+ "Empty until setup fills it in: the product in a paragraph, a map of which repo\n" +
1119
+ "owns what, the grooming guidance Duty 2 needs to judge priority and spot issues\n" +
1120
+ "that would collide, and where the roadmap lives. `omp-conductor setup` asks for\n" +
1121
+ "the last of those and proposes the rest.";
1122
+
1123
+ /**
1124
+ * Prose a probe read out of the repos and the **operator confirmed**.
1125
+ *
1126
+ * Separate from {@link SetupAnswers} on purpose: these are paragraphs of the
1127
+ * operator's brief, not configuration. Nothing here is ever written unconfirmed,
1128
+ * and every field is optional — a declined preview, an unreachable peer and
1129
+ * `--no-ai` all arrive here as the same absence.
1130
+ */
1131
+ export interface ProbedProse {
1132
+ /** The product, the repo map and the grooming guidance Duty 2 needs. */
1133
+ projectContext?: string;
1134
+ /** This repo's real release machinery, expressing the operator's stated intent. */
1135
+ releaseProcedure?: string;
1136
+ }
1137
+
1138
+ /**
1139
+ * Writes the interview's answers, and any confirmed probe prose, into the two
1140
+ * `POLICY.md` stubs that were waiting for them.
1141
+ *
1142
+ * Text substitution rather than a template variable, because these are the
1143
+ * *operator's* words in the operator's file: the conductor renders `POLICY.md`
1144
+ * once and never reads it back, so anything here has to land as prose they can
1145
+ * then edit freely.
1146
+ *
1147
+ * Absent answers leave the shipped guidance in place — a stub that still explains
1148
+ * what belongs there is more use than a heading with nothing under it. What no
1149
+ * outcome leaves behind is a pointer at the onboarding skill #309 deleted: the
1150
+ * template no longer carries one, and `setup.test.ts` pins its absence, because a
1151
+ * stub telling an operator to load something that does not ship is exactly the
1152
+ * capability loss #307 and #309 were sequenced to avoid.
1153
+ */
1154
+ export function fillJudgment(
1155
+ policy: string,
1156
+ judgment: OperatorJudgment | undefined,
1157
+ prose: ProbedProse = {},
1158
+ ): string {
1159
+ const j = judgment ?? {};
1160
+ let out = policy;
1161
+
1162
+ const procedure = prose.releaseProcedure?.trim();
1163
+ const releases = [...releaseLines(j), ...(procedure === undefined || procedure.length === 0 ? [] : ["", procedure])];
1164
+ if (releases.length > 0) {
1165
+ // Appended under the four bullets rather than replacing them: the bullets say
1166
+ // what a good answer contains, and keeping them beside the answer is what
1167
+ // makes a later edit self-checking.
1168
+ out = out.replace(
1169
+ " forbidden everywhere, always.\n",
1170
+ ` forbidden everywhere, always.\n\n${releases.join("\n")}\n`,
1171
+ );
1172
+ }
1173
+
1174
+ out = out.replace(CONTEXT_STUB, contextSection(j.roadmap, prose.projectContext));
1175
+
1176
+ return out;
1177
+ }
1178
+
1179
+ /** The operator's release intent, as the brief's own bullet shape. */
1180
+ function releaseLines(j: OperatorJudgment): string[] {
1181
+ const lines: string[] = [];
1182
+ const add = (label: string, value: string | undefined): void => {
1183
+ if (value !== undefined && value.trim().length > 0) lines.push(`- **${label}** ${value.trim()}`);
1184
+ };
1185
+ add("Your leg ends:", j.boundary);
1186
+ add("What may be released:", j.releaseWhat);
1187
+ add("When:", j.releaseWhen);
1188
+ add("Proof held first:", j.releaseProof);
1189
+ add("Always ask about:", j.releaseAsk);
1190
+ add("Permanently forbidden:", j.releaseForbidden);
1191
+ add("A release worth cutting:", j.worthCutting);
1192
+ add("Rollback owner:", j.rollbackOwner);
1193
+ if (lines.length === 0) return lines;
1194
+ return [
1195
+ "Answered during setup — yours to edit:",
1196
+ "",
1197
+ ...lines,
1198
+ ...(j.rollbackMovesBoundary === true
1199
+ ? [
1200
+ "",
1201
+ "The rollback owner is a person, so the boundary above ends *before* the",
1202
+ "irreversible step: prepare and verify it, then hand it over.",
1203
+ ]
1204
+ : []),
1205
+ ];
1206
+ }
1207
+
970
1208
  /**
971
1209
  * Recompose `ORCHESTRATOR.md` from the package floor + live `POLICY.md`.
972
1210
  *
@@ -1155,8 +1393,8 @@ export function summarisePlan(
1155
1393
  for (const r of graphed) lines.push(` ${r.name} ${r.graphProject}`);
1156
1394
  lines.push(
1157
1395
  " conductor's own index-only clones — nothing human edits them, and",
1158
- " nothing here creates them. Run `omp-conductor graph-setup` after",
1159
- " setup: it prints the clone, index and systemd-timer commands.",
1396
+ " nothing here creates them. Run `omp-conductor setup graph` after",
1397
+ " setup: it clones, installs the timer and seeds the first index.",
1160
1398
  );
1161
1399
  }
1162
1400
 
@@ -1171,8 +1409,12 @@ export function summarisePlan(
1171
1409
 
1172
1410
  lines.push("", "escalation");
1173
1411
  if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
1412
+ const topic =
1413
+ a.telegramTopicId !== undefined && Number.isSafeInteger(a.telegramTopicId)
1414
+ ? ` topic ${a.telegramTopicId}`
1415
+ : " flat chat";
1174
1416
  lines.push(
1175
- ` tier 2 Telegram chat ${a.telegramChatId.trim()}` +
1417
+ ` tier 2 Telegram chat ${a.telegramChatId.trim()}${topic}` +
1176
1418
  (tg.hasToken ? "" : " — WARNING: no bot token found, this will not send"),
1177
1419
  ` via omp-telegram at ${tg.stateDir}`,
1178
1420
  );
@@ -1294,16 +1536,16 @@ export function formatGates(gates: readonly { cmd: string; cwd: string }[]): str
1294
1536
  * The wizard's questions, grouped as the areas a re-run can amend one of, in the
1295
1537
  * order the full interview asks them.
1296
1538
  *
1297
- * Data rather than a switch so the menu, the exhaustiveness of the dialog table
1298
- * in ./plugin.ts, and the amend summary all enumerate the same nine areas: an
1299
- * added area fails to compile until it has a name, a current value and a set of
1300
- * questions.
1539
+ * Data rather than a switch so the menu, the CLI's positional area vocabulary,
1540
+ * ./setup-wizard.ts's `AREA_ASKERS` table and the amend summary all enumerate the
1541
+ * same nine areas: an added area fails to compile until it has a name, a current
1542
+ * value and a set of questions.
1301
1543
  */
1302
1544
  export const AMEND_AREA_IDS = [
1303
1545
  "tracker",
1304
1546
  "gates",
1305
1547
  "caps",
1306
- "graph",
1548
+ "code-graph",
1307
1549
  "authority",
1308
1550
  "policy",
1309
1551
  "escalation",
@@ -1370,7 +1612,7 @@ export const AMEND_AREAS: {
1370
1612
  );
1371
1613
  },
1372
1614
  },
1373
- graph: {
1615
+ "code-graph": {
1374
1616
  name: "code graph",
1375
1617
  asks: "whether workers query a code-graph index, and the root its one-clone-per-repo lives under",
1376
1618
  describe: (p) => {
@@ -1414,12 +1656,14 @@ export const AMEND_AREAS: {
1414
1656
  },
1415
1657
  escalation: {
1416
1658
  name: "escalation & triage",
1417
- asks: "the tier-2 Telegram chat, whether escalations also comment, and where the orchestrator session lives",
1659
+ asks: "the tier-2 Telegram chat, optional forum topic, whether escalations also comment, and where the orchestrator session lives",
1418
1660
  describe: (p) =>
1419
1661
  [
1420
1662
  p.escalation.telegramChatId === undefined
1421
1663
  ? "tier 2 by issue comment only"
1422
- : `tier 2 pages Telegram ${p.escalation.telegramChatId}`,
1664
+ : p.escalation.telegramTopicId === undefined
1665
+ ? `tier 2 pages Telegram ${p.escalation.telegramChatId} (flat chat)`
1666
+ : `tier 2 pages Telegram ${p.escalation.telegramChatId} topic ${p.escalation.telegramTopicId}`,
1423
1667
  p.escalation.fallbackToIssueComment ? "comments too" : "no comment fallback",
1424
1668
  `triage ${p.escalation.orchestrator}`,
1425
1669
  ].join(", "),
package/src/types.ts CHANGED
@@ -112,7 +112,7 @@ export interface RepoTarget {
112
112
  * So this names a disposable clone nothing human ever edits, which is what
113
113
  * makes the reset both safe and deterministic.
114
114
  *
115
- * `omp-conductor graph-setup` prints how to create and refresh it. Nothing in
115
+ * `omp-conductor setup graph` creates and refreshes it. Nothing in
116
116
  * this package reads an index itself: the daemon only passes this path into
117
117
  * the worker brief.
118
118
  */
@@ -594,7 +594,7 @@ export interface ProjectConfig {
594
594
  * How a stuck run reaches a human, what to do when it cannot, and who runs
595
595
  * the session that triages it. See {@link ORCHESTRATOR_MODES}.
596
596
  */
597
- escalation: { telegramChatId?: string; fallbackToIssueComment: boolean; orchestrator: OrchestratorMode };
597
+ escalation: { telegramChatId?: string; telegramTopicId?: number; fallbackToIssueComment: boolean; orchestrator: OrchestratorMode };
598
598
  /**
599
599
  * Who lands green PRs and who cuts releases. The daemon never acts on this
600
600
  * itself — it words the orchestrator's standing orders and the rendered brief
package/src/upgrade.ts CHANGED
@@ -2,7 +2,15 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
3
3
  import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
4
4
  import { setPaused, statusSnapshot } from "./daemon.ts";
5
- import { fleetLayers, telegramStateDir, type DispatchLayer, type FleetLayers } from "./fleet.ts";
5
+ import {
6
+ DEFAULT_HERDR_SESSION,
7
+ fleetLayers,
8
+ LEGACY_HERDR_SESSION_HINT,
9
+ resolveHerdrSession,
10
+ telegramStateDir,
11
+ type DispatchLayer,
12
+ type FleetLayers,
13
+ } from "./fleet.ts";
6
14
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
7
15
  import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
8
16
  import { renderBriefForProject } from "./setup.ts";
@@ -45,7 +53,7 @@ export interface UpgradeDeps {
45
53
  layers(project?: string): FleetLayers;
46
54
  brief(project?: string): { kind: BriefLayout["kind"]; current: boolean };
47
55
  daemonIdentity(): { running: boolean; project?: string };
48
- setPaused(value: boolean): void;
56
+ setPaused(value: boolean, project?: string): void;
49
57
  restartDaemon(): Promise<void>;
50
58
  sleep(ms: number): Promise<void>;
51
59
  env: NodeJS.ProcessEnv;
@@ -85,7 +93,8 @@ export const DEFAULT_DEPS: UpgradeDeps = {
85
93
  const daemon = livingDaemon();
86
94
  return daemon === undefined ? { running: false } : { running: true, project: daemon.project };
87
95
  },
88
- setPaused: (v) => setPaused(v, { source: "upgrade", reason: "upgrade, draining" }),
96
+ setPaused: (v, project) =>
97
+ setPaused(v, { source: "upgrade", reason: "upgrade, draining" }, project),
89
98
  restartDaemon: async () => {
90
99
  await restartDaemon({});
91
100
  },
@@ -170,7 +179,32 @@ interface InstalledSurfaces {
170
179
  }
171
180
 
172
181
  async function inspectSurfaces(deps: UpgradeDeps): Promise<InstalledSurfaces> {
173
- const session = deps.env["HERDR_SESSION"] || "fleet";
182
+ // HERDR_SESSION is authoritative. Otherwise prefer DEFAULT_HERDR_SESSION, with
183
+ // #320's one-release bridge to a populated legacy "fleet" session.
184
+ let session = resolveHerdrSession(deps.env);
185
+ if (deps.env["HERDR_SESSION"] === undefined || deps.env["HERDR_SESSION"] === "") {
186
+ // Prefer a session that answers plugin list; fall back to conductor.
187
+ let conductorOk = false;
188
+ let fleetOk = false;
189
+ try {
190
+ const r = await deps.run("herdr", ["--session", DEFAULT_HERDR_SESSION, "plugin", "list"]);
191
+ conductorOk = r.code === 0;
192
+ } catch {
193
+ conductorOk = false;
194
+ }
195
+ try {
196
+ const r = await deps.run("herdr", ["--session", "fleet", "plugin", "list"]);
197
+ fleetOk = r.code === 0;
198
+ } catch {
199
+ fleetOk = false;
200
+ }
201
+ if (!conductorOk && fleetOk) {
202
+ session = "fleet";
203
+ deps.log(LEGACY_HERDR_SESSION_HINT);
204
+ } else {
205
+ session = DEFAULT_HERDR_SESSION;
206
+ }
207
+ }
174
208
  const [cli, omp, herdr] = await Promise.all([
175
209
  mustRun(deps, "omp-conductor", ["--version"]),
176
210
  mustRun(deps, "omp", ["plugin", "list", "--json"]),
@@ -245,10 +279,10 @@ export async function drainAndRestart(
245
279
  o: { project?: string; timeoutMs: number },
246
280
  ): Promise<void> {
247
281
  const initial = deps.layers(o.project);
248
- if (!initial.paused) deps.setPaused(true);
282
+ if (!initial.paused) deps.setPaused(true, o.project);
249
283
  await waitForDrain(deps, o.project, Date.now() + o.timeoutMs);
250
284
  await deps.restartDaemon();
251
- if (!initial.paused) deps.setPaused(false);
285
+ if (!initial.paused) deps.setPaused(false, o.project);
252
286
  }
253
287
 
254
288
  function recoveryProblem(
@@ -534,7 +568,7 @@ export async function upgradeConductor(
534
568
  deps.log(`target release: omp-conductor@${release.version} (${release.gitHead})`);
535
569
  if (!initial.paused) {
536
570
  deps.log("safety: pausing new issue claims");
537
- deps.setPaused(true);
571
+ deps.setPaused(true, project);
538
572
  }
539
573
  deps.log("drain: waiting for live omp worker sessions");
540
574
  try {
@@ -606,7 +640,7 @@ export async function upgradeConductor(
606
640
  await deps.sleep(1_000);
607
641
  await waitForRecovery(deps, initial, project);
608
642
 
609
- if (!initial.paused) deps.setPaused(false);
643
+ if (!initial.paused) deps.setPaused(false, project);
610
644
  const restoredDispatch = deps.layers(project).dispatch;
611
645
  if (restoredDispatch !== initial.dispatch) {
612
646
  throw new Error(
@@ -615,9 +649,9 @@ export async function upgradeConductor(
615
649
  }
616
650
  } catch (err) {
617
651
  try {
618
- if (!deps.layers(project).paused) deps.setPaused(true);
652
+ if (!deps.layers(project).paused) deps.setPaused(true, project);
619
653
  } catch {
620
- deps.setPaused(true);
654
+ deps.setPaused(true, project);
621
655
  }
622
656
  const failure = err instanceof Error ? err.message : String(err);
623
657
  deps.log(`upgrade failed: ${failure}`);
@@ -43,7 +43,7 @@ import { createServer, type Server, type Socket } from "node:net";
43
43
 
44
44
  import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
45
45
  import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
46
- import type { readBaseChain as readBaseChainType } from "../gitops.ts";
46
+ import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
47
47
  import { releaseRefusal } from "../release-policy.ts";
48
48
  import { LIVE_STATES } from "../store.ts";
49
49
  import type {
@@ -61,6 +61,7 @@ import type {
61
61
  VerbName,
62
62
  VerbRefusal,
63
63
  } from "../types.ts";
64
+ import { prUrlParts } from "../tracker/github.ts";
64
65
  import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
65
66
  import {
66
67
  peerVerdict,
@@ -637,6 +638,20 @@ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | u
637
638
  .find((candidate) => candidate.prUrl === prUrl);
638
639
  }
639
640
 
641
+ /**
642
+ * Whether a PR URL names a repository this project routes.
643
+ *
644
+ * Orchestrator status / update-branch may act on open project PRs that no run
645
+ * opened (workflow-generated release pins, etc.). Scope is the routed clone
646
+ * slugs — not "any PR a run once recorded".
647
+ */
648
+ function prInProjectRouting(project: ProjectConfig, prUrl: string): boolean {
649
+ const parts = prUrlParts(prUrl);
650
+ if (parts === undefined) return false;
651
+ const slug = `${parts.owner}/${parts.repo}`;
652
+ return Object.values(project.routing.repos).some((repo) => repoSlugFor(repo) === slug);
653
+ }
654
+
640
655
  /** Refuse an orchestrator completion mutation that cannot prove a pre-pause run. */
641
656
  function orchestratorPauseRefusal(
642
657
  deps: VerbDeps,
@@ -688,8 +703,12 @@ async function prUpdateBranchVerb(
688
703
  target = own;
689
704
  } else {
690
705
  target = runForPr(deps, project.name, prUrl);
691
- if (target === undefined) {
692
- return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
706
+ if (target === undefined && !prInProjectRouting(project, prUrl)) {
707
+ return refuse(
708
+ "pr-not-this-run",
709
+ `refused: ${prUrl} is not a pull request in ${project.name}'s routed repositories ` +
710
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
711
+ );
693
712
  }
694
713
  }
695
714
 
@@ -709,15 +728,15 @@ async function prUpdateBranchVerb(
709
728
  state === undefined
710
729
  ? `refused: the tracker could not say whether ${prUrl} is open. Refusing rather than acting on an unknown state.`
711
730
  : `refused: ${prUrl} is ${state}, not open.`,
712
- target.issue,
731
+ target?.issue,
713
732
  );
714
733
  }
715
734
 
716
735
  const outcome = await deps.actions.updatePrBranch(prUrl);
717
736
  if (!outcome.ok) {
718
- return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target.issue);
737
+ return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target?.issue);
719
738
  }
720
- return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target.issue);
739
+ return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target?.issue);
721
740
  }
722
741
 
723
742
  async function prMergeVerb(
@@ -1061,11 +1080,15 @@ async function prStatusVerb(
1061
1080
  return refuse("malformed-argument", "refused: conductor_pr_status needs prUrl when it has no run to infer one from.");
1062
1081
  }
1063
1082
  const target = runForPr(deps, project.name, asked);
1064
- if (target === undefined) {
1065
- return refuse("pr-not-this-run", `refused: ${asked} is not a pull request any run in ${project.name} opened.`);
1083
+ if (target === undefined && !prInProjectRouting(project, asked)) {
1084
+ return refuse(
1085
+ "pr-not-this-run",
1086
+ `refused: ${asked} is not a pull request in ${project.name}'s routed repositories ` +
1087
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
1088
+ );
1066
1089
  }
1067
1090
  prUrl = asked;
1068
- issue = target.issue;
1091
+ issue = target?.issue;
1069
1092
  }
1070
1093
 
1071
1094
  const askedHead = args["headSha"];