omp-conductor 0.15.6 → 0.15.8

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.
@@ -21,6 +21,7 @@ import {
21
21
  resolveCaps,
22
22
  saveConfig,
23
23
  } from "./config.ts";
24
+ import { claimedTelegramTopics } from "./escalate.ts";
24
25
  import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
25
26
  import {
26
27
  prepareConductor,
@@ -42,6 +43,11 @@ import {
42
43
  } from "./setup-host.ts";
43
44
  import { runGraphInstall, runHostInstall } from "./setup-install.ts";
44
45
  import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts";
46
+ import {
47
+ discoverFacts,
48
+ type DiscoveredFacts,
49
+ type DiscoveryTarget,
50
+ } from "./setup-discover.ts";
45
51
  import {
46
52
  AMEND_AREAS,
47
53
  BASE_FRESHNESS_CHOICES,
@@ -459,10 +465,12 @@ async function askAuthority(
459
465
  * shape cannot be added without a question to ask about it — an unasked shape
460
466
  * would silently take the deny default and read as a decision afterwards.
461
467
  *
462
- * No fleet vocabulary here on purpose (#122): every one of these is an act the
463
- * package can recognise anywhere, not a step in one project's release topology.
468
+ * No fleet-specific vocabulary here (#122): these are generic release acts.
469
+ * Version preparation becomes executable only when a repo declares its version
470
+ * file; package names, suite pins and deployment topology still stay in policy.
464
471
  */
465
472
  const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]: string } = {
473
+ "version-bump-pr": "open and land an exact version-only pull request before tagging",
466
474
  "git-tag": "create git tags (`git tag v1.2.3`)",
467
475
  "git-push-tags": "push tags to the remote (`git push --follow-tags`)",
468
476
  "package-publish": "publish packages (`npm publish` and equivalents)",
@@ -489,14 +497,26 @@ async function askJudgment(
489
497
  ui: WizardUi,
490
498
  grants: ResolvedGrants,
491
499
  prior: OperatorJudgment,
500
+ roadmapChoices: readonly string[] = [],
492
501
  ): Promise<OperatorJudgment> {
493
502
  // Always asked. A tracker shows what is open, never what matters, and an
494
503
  // orchestrator that cannot rank work grooms by recency — which is how a stale
495
504
  // issue outranks the thing being shipped this month.
505
+ let roadmapSeed = prior.roadmap ?? "";
506
+ if (roadmapSeed === "" && roadmapChoices.length > 0) {
507
+ const other = "Type a different roadmap or priority";
508
+ const picked = await ui.select(
509
+ "Roadmap candidates found in GitHub",
510
+ [...roadmapChoices.map((label) => ({ label })), { label: other }],
511
+ { initialIndex: 0 },
512
+ );
513
+ if (picked === undefined) throw new Cancelled();
514
+ roadmapSeed = picked === other ? "" : picked;
515
+ }
496
516
  const roadmap = await ask(
497
517
  ui,
498
518
  "Where does the roadmap live, and what is the current priority?",
499
- prior.roadmap ?? "",
519
+ roadmapSeed,
500
520
  );
501
521
  const judgment: OperatorJudgment = { ...prior, ...(roadmap.length === 0 ? {} : { roadmap }) };
502
522
 
@@ -735,7 +755,12 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
735
755
  * prompts or the defaults they pre-fill from: the value shown is always the value
736
756
  * that would otherwise be carried through.
737
757
  */
738
- type AreaAsker = (ui: WizardUi, a: SetupAnswers, probes: SetupProbes) => Promise<SetupAnswers>;
758
+ type AreaAsker = (
759
+ ui: WizardUi,
760
+ a: SetupAnswers,
761
+ probes: SetupProbes,
762
+ discovered?: DiscoveredFacts,
763
+ ) => Promise<SetupAnswers>;
739
764
 
740
765
  /**
741
766
  * The repo-reading half of onboarding, injected rather than called directly.
@@ -747,6 +772,8 @@ type AreaAsker = (ui: WizardUi, a: SetupAnswers, probes: SetupProbes) => Promise
747
772
  * peer, a private repo, or a model that answered in prose.
748
773
  */
749
774
  export interface SetupProbes {
775
+ /** Finds exact checkout/GitHub facts. `{}` seeds nothing. */
776
+ discover(target: DiscoveryTarget): Promise<DiscoveredFacts>;
750
777
  /** Proposes a repo's pre-push gates by reading its CI. `[]` seeds nothing. */
751
778
  gates(ui: WizardUi, target: ProbeTarget): Promise<ProbedGate[]>;
752
779
  /**
@@ -758,12 +785,77 @@ export interface SetupProbes {
758
785
 
759
786
  /** Reads each repo to propose answers. */
760
787
  export const DEFAULT_PROBES: SetupProbes = {
788
+ discover: (target) => discoverFacts(target),
761
789
  gates: (ui, target) => probeGates(ui, target),
762
790
  prose: (ui, a) => proseFromRepos(ui, a),
763
791
  };
764
792
 
765
793
  /** `--no-ai`, and every wizard test: nothing is read, nothing is proposed. */
766
- export const NO_PROBES: SetupProbes = { gates: async () => [], prose: async () => ({}) };
794
+ export const NO_PROBES: SetupProbes = {
795
+ discover: async () => ({}),
796
+ gates: async () => [],
797
+ prose: async () => ({}),
798
+ };
799
+
800
+ /** Exact facts replace only fresh defaults; saved operator answers always win. */
801
+ function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAnswers {
802
+ const labels = new Map((found.labels ?? []).map((name) => [name.toLowerCase(), name]));
803
+ const trackerRepo = seed.trackerRepo || found.trackerRepo || "";
804
+ const targetRepos =
805
+ seed.targetRepos.length > 0 ||
806
+ found.routingKey === undefined ||
807
+ found.cloneUrl === undefined
808
+ ? seed.targetRepos
809
+ : [
810
+ {
811
+ name: found.routingKey,
812
+ cloneUrl: found.cloneUrl,
813
+ defaultBranch: found.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
814
+ gates: [],
815
+ },
816
+ ];
817
+ return {
818
+ ...seed,
819
+ trackerRepo,
820
+ queueLabel: labels.get(seed.queueLabel.toLowerCase()) ?? seed.queueLabel,
821
+ stateLabels: {
822
+ inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress,
823
+ blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked,
824
+ failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed,
825
+ },
826
+ targetRepos,
827
+ policy: {
828
+ merge: {
829
+ ...seed.policy.merge,
830
+ requiredChecks: found.requiredChecks ?? seed.policy.merge.requiredChecks,
831
+ },
832
+ release: {
833
+ ...seed.policy.release,
834
+ artefacts: found.artefacts ?? seed.policy.release.artefacts,
835
+ environments: found.environments ?? seed.policy.release.environments,
836
+ },
837
+ },
838
+ };
839
+ }
840
+
841
+ function showDiscovery(ui: WizardUi, found: DiscoveredFacts): void {
842
+ if ((found.evidence?.length ?? 0) > 0) {
843
+ ui.notify(
844
+ [
845
+ "Discovered setup defaults:",
846
+ ...(found.evidence ?? []).map((line) => ` ${line}`),
847
+ "Every value remains editable at its prompt.",
848
+ ].join("\n"),
849
+ "info",
850
+ );
851
+ }
852
+ if ((found.skipped?.length ?? 0) > 0) {
853
+ ui.notify(
854
+ ["Some setup discovery was skipped; today's typed defaults remain:", ...(found.skipped ?? []).map((line) => ` ${line}`)].join("\n"),
855
+ "warning",
856
+ );
857
+ }
858
+ }
767
859
 
768
860
  /**
769
861
  * The brief's two prose halves, drafted against **every** configured repo.
@@ -1047,14 +1139,14 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
1047
1139
 
1048
1140
  /** The two ownership questions, then the mechanical gate one shape at a time:
1049
1141
  * together they are what decides what an unattended fleet may do unasked. */
1050
- const askAuthorityArea: AreaAsker = async (ui, a) => {
1142
+ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1051
1143
  const authority = await askAuthority(ui, a.authority);
1052
1144
  const releaseGrants = await askReleaseGrants(ui, a.releaseGrants);
1053
1145
  // Asked here because it is the same decision one layer down: the grants say
1054
1146
  // who may act, and these say where that permission stops. Amending authority
1055
1147
  // therefore re-asks the boundary, which is the point — a grant widened without
1056
1148
  // restating the boundary is how a delegated release loses its end.
1057
- const judgment = await askJudgment(ui, releaseGrants, a.judgment ?? {});
1149
+ const judgment = await askJudgment(ui, releaseGrants, a.judgment ?? {}, discovered?.roadmaps);
1058
1150
  return { ...a, authority, releaseGrants, judgment };
1059
1151
  };
1060
1152
 
@@ -1117,7 +1209,7 @@ async function askTelegramTopicId(
1117
1209
  stateDir: string,
1118
1210
  prior: number | undefined,
1119
1211
  ): Promise<number | undefined> {
1120
- const claimed = readClaimedTelegramTopics(stateDir);
1212
+ const claimed = claimedTelegramTopics(stateDir);
1121
1213
  const manual = "Enter thread id manually";
1122
1214
  const none = "None — flat chat (0.13 behaviour)";
1123
1215
  if (claimed.length > 0) {
@@ -1155,29 +1247,6 @@ async function askTelegramTopicId(
1155
1247
  return n;
1156
1248
  }
1157
1249
 
1158
- function readClaimedTelegramTopics(stateDir: string): Array<{ threadId: number; name: string }> {
1159
- try {
1160
- const raw: unknown = JSON.parse(readFileSync(join(stateDir, "threads.json"), "utf8"));
1161
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
1162
- if (!("threads" in raw)) return [];
1163
- const threads = raw.threads;
1164
- if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
1165
- const out: Array<{ threadId: number; name: string }> = [];
1166
- for (const [id, entry] of Object.entries(threads)) {
1167
- const threadId = Number(id);
1168
- if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
1169
- let name = id;
1170
- if (typeof entry === "object" && entry !== null && "name" in entry) {
1171
- const candidate = entry.name;
1172
- if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
1173
- }
1174
- out.push({ threadId, name });
1175
- }
1176
- return out;
1177
- } catch {
1178
- return [];
1179
- }
1180
- }
1181
1250
 
1182
1251
  /** How loud the orchestrator is and when the operator permits interruptions. */
1183
1252
  const askReporting: AreaAsker = async (ui, a) => {
@@ -1432,12 +1501,17 @@ async function collectAnswers(
1432
1501
  probes: SetupProbes,
1433
1502
  opts: { added?: boolean } = {},
1434
1503
  ): Promise<SetupAnswers> {
1504
+ // Discovery is a fresh-interview default only. Re-runs start from the saved
1505
+ // project, so an operator-edited value is never re-guessed.
1506
+ const discovered = prior === undefined ? await probes.discover({ cwd: process.cwd() }) : {};
1507
+ showDiscovery(ui, discovered);
1435
1508
  // Added projects seed under projects/<name>/; a first install and a re-interview
1436
1509
  // of an existing project keep the flat (or already-on-disk) roots.
1437
- const seed =
1510
+ const baseSeed =
1438
1511
  prior === undefined
1439
1512
  ? defaultAnswers(projectArg ?? "", { added: opts.added === true })
1440
1513
  : answersFromProject(prior);
1514
+ const seed = prior === undefined ? seedFromDiscovery(baseSeed, discovered) : baseSeed;
1441
1515
 
1442
1516
  const projectName = await askValid(
1443
1517
  ui,
@@ -1462,8 +1536,8 @@ async function collectAnswers(
1462
1536
  // routed repo, under one root.
1463
1537
  a = await askGraph(ui, a, probes);
1464
1538
  a = await askCaps(ui, a, probes);
1465
- a = await askAuthorityArea(ui, a, probes);
1466
- a = await askPolicy(ui, a, probes);
1539
+ a = await askAuthorityArea(ui, a, probes, discovered);
1540
+ a = await askPolicy(ui, a, probes, discovered);
1467
1541
  a = await askWorkerModel(ui, a, probes);
1468
1542
  a = await askEscalation(ui, a, probes);
1469
1543
  a = await askReporting(ui, a, probes);
package/src/setup.ts CHANGED
@@ -131,7 +131,14 @@ export interface SetupAnswers {
131
131
  queueLabel: string;
132
132
  stateLabels: { inProgress: string; blocked: string; failed: string };
133
133
  routingLabelPrefix: string;
134
- targetRepos: { name: string; cloneUrl: string; defaultBranch: string; gates: { cmd: string; cwd: string }[] }[];
134
+ targetRepos: {
135
+ name: string;
136
+ cloneUrl: string;
137
+ defaultBranch: string;
138
+ gates: { cmd: string; cwd: string }[];
139
+ migrations?: { dir: string };
140
+ release?: { versionFile: string };
141
+ }[];
135
142
  caps: Partial<Caps>;
136
143
  /**
137
144
  * Model pattern for worker sessions, in omp's model/role syntax. Absent means
@@ -170,6 +177,13 @@ export interface SetupAnswers {
170
177
  * leave one to be defaulted by whichever reader gets there first.
171
178
  */
172
179
  policy: ProjectPolicy;
180
+ /**
181
+ * Hand-edited recovery merge authorizations carried through setup unchanged.
182
+ * The wizard never grants one; forgetting them during an unrelated amend
183
+ * would strand an in-progress recovery, while inventing one would weaken the
184
+ * merge provenance gate.
185
+ */
186
+ recoveryMerges?: ProjectConfig["recoveryMerges"];
173
187
  /**
174
188
  * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
175
189
  * part of the config — the brief is the operator's file, and the conductor
@@ -702,6 +716,8 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
702
716
  ...(graphRoot === undefined || graphRoot.length === 0
703
717
  ? {}
704
718
  : { graphProject: graphProjectPath(graphRoot, r.name) }),
719
+ ...(r.migrations === undefined ? {} : { migrations: { ...r.migrations } }),
720
+ ...(r.release === undefined ? {} : { release: { ...r.release } }),
705
721
  };
706
722
  }
707
723
 
@@ -749,6 +765,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
749
765
  // Written out in full for the same reason: the file then says what a merge
750
766
  // and a release require without anyone having to know a default (#129).
751
767
  policy: clonePolicy(a.policy),
768
+ ...(a.recoveryMerges === undefined
769
+ ? {}
770
+ : { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
752
771
  // Written out even when it is the default, so an operator amending the
753
772
  // volume has a line in the file to point at. Opting into availability makes
754
773
  // the schedule explicit and daily; omitting it preserves the preset's
@@ -918,6 +937,8 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
918
937
  cloneUrl: r.cloneUrl,
919
938
  defaultBranch: r.defaultBranch,
920
939
  gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
940
+ ...(r.migrations === undefined ? {} : { migrations: { ...r.migrations } }),
941
+ ...(r.release === undefined ? {} : { release: { ...r.release } }),
921
942
  })),
922
943
  caps: { ...p.caps },
923
944
  fallbackToIssueComment: p.escalation.fallbackToIssueComment,
@@ -938,6 +959,9 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
938
959
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
939
960
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
940
961
  if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
962
+ if (p.recoveryMerges !== undefined) {
963
+ answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
964
+ }
941
965
  if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
942
966
  if (p.reporting?.digest.timezone !== undefined) {
943
967
  answers.reportingTimezone = p.reporting.digest.timezone;
package/src/store.ts CHANGED
@@ -864,11 +864,15 @@ export function openStore(dbPath: string): Store {
864
864
 
865
865
  const db = new Database(dbPath, { create: true });
866
866
 
867
+ // Set the wait before *any* pragma that may need SQLite's writer lock.
868
+ // journal_mode itself can return SQLITE_BUSY during another connection's
869
+ // close/checkpoint; setting this afterwards caused load-only CLI flakes
870
+ // before the schema path ever had a chance to use the timeout (#396).
871
+ db.exec("PRAGMA busy_timeout = 5000;");
867
872
  // WAL lets the plugin read status while the daemon is mid-write; the busy
868
873
  // timeout covers the single writer lock they still contend for.
869
874
  db.exec("PRAGMA journal_mode = WAL;");
870
875
  db.exec("PRAGMA foreign_keys = ON;");
871
- db.exec("PRAGMA busy_timeout = 5000;");
872
876
  db.exec(SCHEMA);
873
877
  // #274: a terminally failed digest did not count as sent, but the old unique
874
878
  // index still blocked a replacement under the same daily key. Replace that
package/src/types.ts CHANGED
@@ -125,6 +125,13 @@ export interface RepoTarget {
125
125
  * = no chain check.
126
126
  */
127
127
  migrations?: { dir: string };
128
+ /**
129
+ * Repository-native release preparation. `versionFile` is a repo-relative
130
+ * JSON file with a top-level string `version` (for example
131
+ * `omp/package.json`). A delegated tag is refused until that live value
132
+ * matches the requested tag; `version-bump-pr` is the mediated review path.
133
+ */
134
+ release?: { versionFile: string };
128
135
  }
129
136
 
130
137
  /**
@@ -237,6 +244,7 @@ export const DEFAULT_REPORT_POLICY: ReportingPolicy = {
237
244
  * dead config nor be asked about them by the wizard.
238
245
  */
239
246
  export const RELEASE_SHAPES = [
247
+ "version-bump-pr",
240
248
  "git-tag",
241
249
  "git-push-tags",
242
250
  "package-publish",
@@ -279,6 +287,7 @@ export const LEGACY_RELEASE_POLICIES = ["none", "operator-brief"] as const;
279
287
  * and `undefined !== role` is only accidentally safe.
280
288
  */
281
289
  export const DENIED_RELEASE_GRANTS: ResolvedGrants = {
290
+ "version-bump-pr": "human",
282
291
  "git-tag": "human",
283
292
  "git-push-tags": "human",
284
293
  "package-publish": "human",
@@ -293,6 +302,7 @@ export const DENIED_RELEASE_GRANTS: ResolvedGrants = {
293
302
  * gave — see #122.
294
303
  */
295
304
  export const OPERATOR_BRIEF_GRANTS: ResolvedGrants = {
305
+ "version-bump-pr": "orchestrator",
296
306
  "git-tag": "orchestrator",
297
307
  "git-push-tags": "orchestrator",
298
308
  "package-publish": "orchestrator",
@@ -492,6 +502,21 @@ export const MERGE_REASONS = [
492
502
 
493
503
  export type MergeReason = (typeof MERGE_REASONS)[number];
494
504
 
505
+ /**
506
+ * A one-PR provenance exception an operator placed in config for recovery.
507
+ *
508
+ * It replaces broad standing merge authority for this exact request and lets
509
+ * an orchestrator present an otherwise-unrecorded PR to the ordinary merge
510
+ * gate, including while dispatch is held. It does not relax the normal merge
511
+ * checks. Exact URL, head and reason binding keeps the exception narrower
512
+ * than the action it authorizes (#382).
513
+ */
514
+ export interface RecoveryMergeAuthorization {
515
+ prUrl: string;
516
+ headSha: string;
517
+ reason: Extract<MergeReason, "operator-instructed">;
518
+ }
519
+
495
520
  /** Why a release is being cut now. Closed for the reason {@link MERGE_REASONS} is. */
496
521
  export const RELEASE_REASONS = [
497
522
  /** The batching unit the operator described in POLICY.md has been reached. */
@@ -622,6 +647,13 @@ export interface ProjectConfig {
622
647
  * reaching for `.merge` on a project some test hand-built.
623
648
  */
624
649
  policy?: ProjectPolicy;
650
+ /**
651
+ * Exact operator-authored exceptions for recovery PRs that no run recorded.
652
+ * Optional and hand-edited: setup preserves entries but never invents them.
653
+ * Each entry still passes routing, live-head, checks, migration-chain, and
654
+ * single-flight gates.
655
+ */
656
+ recoveryMerges?: RecoveryMergeAuthorization[];
625
657
  /**
626
658
  * How loud the orchestrator is, and when the daily rollup happens. Optional
627
659
  * on disk — a config written before this key existed loads as
@@ -1836,6 +1868,8 @@ export const VERB_REFUSALS = [
1836
1868
  "open-pr-lookup-error",
1837
1869
  /** The named pull request is not this run's, or not this project's. */
1838
1870
  "pr-not-this-run",
1871
+ /** An unrecorded recovery PR was authorized, but not for these exact inputs. */
1872
+ "recovery-authorization-mismatch",
1839
1873
  /** The run has no pull request to act on. */
1840
1874
  "pr-missing",
1841
1875
  /**