omp-conductor 0.3.25 → 0.4.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
@@ -32,21 +32,39 @@ import {
32
32
  refreshComposedBrief,
33
33
  renderBriefTemplate,
34
34
  } from "./brief-upgrade.ts";
35
- import { configPath, resolveCaps, resolveReleasePolicy, stateDir } from "./config.ts";
35
+ import {
36
+ clonePolicy,
37
+ configPath,
38
+ resolveCaps,
39
+ resolveCredentials,
40
+ resolvePolicy,
41
+ resolveReleaseGrants,
42
+ projectTreeBase,
43
+ stateDir,
44
+ } from "./config.ts";
36
45
  import { graphProjectPath, graphRepos } from "./graph.ts";
37
46
  import {
38
47
  CONFIG_VERSION,
39
48
  DEFAULT_AUTHORITY,
40
49
  DEFAULT_CAPS,
41
- DEFAULT_RELEASE_POLICY,
50
+ DEFAULT_PROJECT_POLICY,
42
51
  DEFAULT_REPORT_SCOPE,
52
+ DENIED_RELEASE_GRANTS,
53
+ RELEASE_SHAPES,
54
+ type CredentialConfig,
55
+ type CredentialIsolation,
56
+ type BaseFreshness,
57
+ type BehindBaseAction,
43
58
  type Caps,
44
59
  type ConductorConfig,
60
+ type DraftPolicy,
45
61
  type OrchestratorMode,
46
62
  type ProjectConfig,
47
- type ReleasePolicy,
63
+ type ProjectPolicy,
64
+ type ReleaseRequirement,
48
65
  type ReportScope,
49
66
  type RepoTarget,
67
+ type ResolvedGrants,
50
68
  } from "./types.ts";
51
69
 
52
70
  /**
@@ -73,8 +91,25 @@ export interface SetupAnswers {
73
91
  fallbackToIssueComment: boolean;
74
92
  /** How loud the supervising orchestrator session should be. */
75
93
  reportScope: ReportScope;
76
- /** Mechanical gate for release/deploy-shaped tool calls. */
77
- releasePolicy: ReleasePolicy;
94
+ /**
95
+ * Mechanical gate for release/deploy-shaped tool calls, per shape. Always
96
+ * complete: the wizard asks about every shape, so an answer object can never
97
+ * leave one to be defaulted by whichever reader gets there first (#122).
98
+ */
99
+ releaseGrants: ResolvedGrants;
100
+ /**
101
+ * What a merge or a release must satisfy before it happens (#129). Always
102
+ * complete: the wizard asks about every field, so an answers object can never
103
+ * leave one to be defaulted by whichever reader gets there first.
104
+ */
105
+ policy: ProjectPolicy;
106
+ /**
107
+ * Whether model-executed code runs under its own OS principal (#125). The
108
+ * wizard offers `per-run` only when the host probe says this box can build
109
+ * the boundary, because an answer the host cannot honour is a fleet that
110
+ * refuses to dispatch rather than a fleet that is protected.
111
+ */
112
+ credentials: CredentialConfig;
78
113
  /**
79
114
  * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
80
115
  * part of the config — the brief is the operator's file, and the conductor
@@ -135,8 +170,17 @@ export const SETUP_DEFAULTS = {
135
170
  defaultBranch: "main",
136
171
  /** Both authorities start with the human; the wizard asks to move each one. */
137
172
  authority: DEFAULT_AUTHORITY,
138
- /** Mechanical release/deploy gate stays closed until explicitly opened. */
139
- releasePolicy: DEFAULT_RELEASE_POLICY,
173
+ /** Every release/deploy shape stays closed until explicitly granted. */
174
+ releaseGrants: DENIED_RELEASE_GRANTS,
175
+ /** The strictest reading of the prose these conditions replaced (#129). */
176
+ policy: DEFAULT_PROJECT_POLICY,
177
+ /**
178
+ * No boundary until an operator asks for one on a host that can build it.
179
+ * `none` is the honest default rather than the safe-sounding one: writing
180
+ * `per-run` into a fresh config on a box with no mechanism would produce a
181
+ * conductor that installs cleanly and then declines every issue (#125).
182
+ */
183
+ credentials: { isolation: "none" } as CredentialConfig,
140
184
  /** The daemon runs its own triage session unless an operator already runs one. */
141
185
  orchestratorMode: "embedded",
142
186
  } as const;
@@ -160,6 +204,39 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
160
204
  },
161
205
  ];
162
206
 
207
+ /**
208
+ * What each precondition value means to the operator being asked about it, in
209
+ * their words rather than the gate's.
210
+ *
211
+ * Mapped over the closed unions rather than written as parallel arrays, for the
212
+ * reason {@link MERGE_DUTY} is: a fifth behind-base action fails to compile here
213
+ * instead of reaching a wizard with no question for it, an amend row that cannot
214
+ * describe it, and a plan summary that shows it blank. One vocabulary, three
215
+ * consumers (#129).
216
+ */
217
+ export const BASE_FRESHNESS_CHOICES: { readonly [K in BaseFreshness]: string } = {
218
+ "up-to-date": "the head must be level with its base before it may merge",
219
+ any: "merge whatever the base has moved to since — CI's verdict may be stale",
220
+ };
221
+
222
+ export const DRAFT_POLICY_CHOICES: { readonly [K in DraftPolicy]: string } = {
223
+ block: "never merge a draft — the author has said the work is not finished",
224
+ allow: "a draft may merge when everything else passes",
225
+ };
226
+
227
+ export const BEHIND_BASE_CHOICES: { readonly [K in BehindBaseAction]: string } = {
228
+ "update-branch": "run `gh pr update-branch` and wait for the fresh run",
229
+ hold: "leave it for whoever refreshes it, and merge nothing",
230
+ escalate: "page a human rather than guess",
231
+ };
232
+
233
+ export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]: string } = {
234
+ "runs-settled": "every run this release covers actually merged, not merely reached a green PR",
235
+ "no-open-prs": "no pull request is still open against the branch being released",
236
+ "queue-drained": "nothing still carries the queue label",
237
+ "epic-children-closed": "the epic this release closes has no open children",
238
+ };
239
+
163
240
  /**
164
241
  * The Releases paragraph the brief opens with, one per authority combination.
165
242
  *
@@ -425,7 +502,11 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
425
502
  * with {@link answersFromProject} — the pair an amend's carry-through rests on.
426
503
  */
427
504
  export function buildProject(a: SetupAnswers): ProjectConfig {
428
- const dir = stateDir();
505
+ // Under a per-run boundary the worktrees and mirrors have to sit where a slot
506
+ // principal can actually traverse to them. Defaulting them under the 0700
507
+ // state directory would provision a repository and then be refused at
508
+ // dispatch, which looks like a broken install rather than a layout choice.
509
+ const dir = projectTreeBase(a.credentials.isolation);
429
510
 
430
511
  const repos: Record<string, RepoTarget> = {};
431
512
  const graphRoot = a.graphRoot?.trim();
@@ -456,7 +537,11 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
456
537
  // Drops `undefined` members so an unanswered cap is absent from the JSON
457
538
  // rather than present-and-null, which the validator would have to reject.
458
539
  for (const [key, value] of Object.entries(a.caps) as [keyof Caps, number | undefined][]) {
459
- if (typeof value === "number") caps[key] = value;
540
+ // `planUsage` is the one cap that is not a number, and the wizard does not
541
+ // ask for it: naming a provider allowance window means reading
542
+ // `omp usage --json` on the fleet host first, so it stays a documented
543
+ // hand-edited key (#110). Skipped here to keep this numeric loop honest.
544
+ if (key !== "planUsage" && typeof value === "number") caps[key] = value;
460
545
  }
461
546
 
462
547
  return {
@@ -471,7 +556,15 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
471
556
  : {}),
472
557
  escalation,
473
558
  authority: { ...a.authority },
474
- releasePolicy: a.releasePolicy,
559
+ // Written out in full, never as the legacy string: the file then says which
560
+ // shapes are open without anyone having to know a migration rule (#122).
561
+ releasePolicy: { ...a.releaseGrants },
562
+ // Written out in full for the same reason: the file then says what a merge
563
+ // and a release require without anyone having to know a default (#129).
564
+ policy: clonePolicy(a.policy),
565
+ // Written out even when it is the default, so `status` reporting the fleet
566
+ // unprotected always has a line in the file to point at (#125).
567
+ credentials: { ...a.credentials },
475
568
  reporting: { scope: a.reportScope },
476
569
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
477
570
  // uninstall, and neither can land in a repo the daemon then tries to commit.
@@ -528,7 +621,9 @@ export function defaultAnswers(projectName: string): SetupAnswers {
528
621
  caps: {},
529
622
  fallbackToIssueComment: true,
530
623
  authority: { ...SETUP_DEFAULTS.authority },
531
- releasePolicy: SETUP_DEFAULTS.releasePolicy,
624
+ releaseGrants: { ...SETUP_DEFAULTS.releaseGrants },
625
+ policy: clonePolicy(SETUP_DEFAULTS.policy),
626
+ credentials: { ...SETUP_DEFAULTS.credentials },
532
627
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
533
628
  reportScope: DEFAULT_REPORT_SCOPE,
534
629
  writeOrchestratorBrief: false,
@@ -574,7 +669,9 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
574
669
  caps: { ...p.caps },
575
670
  fallbackToIssueComment: p.escalation.fallbackToIssueComment,
576
671
  authority: { ...p.authority },
577
- releasePolicy: resolveReleasePolicy(p),
672
+ releaseGrants: resolveReleaseGrants(p),
673
+ policy: resolvePolicy(p),
674
+ credentials: resolveCredentials(p),
578
675
  orchestratorMode: p.escalation.orchestrator,
579
676
  reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
580
677
  writeOrchestratorBrief: false,
@@ -614,9 +711,27 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
614
711
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
615
712
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
616
713
  REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
714
+ POLICY_SOURCE: policySourceLine(p),
617
715
  };
618
716
  }
619
717
 
718
+ /**
719
+ * Where the merge and release conditions actually live, worded for this
720
+ * project.
721
+ *
722
+ * A pointer, deliberately carrying no value from the policy it points at. A
723
+ * threshold rendered into the standing prompt *and* stored in the config is a
724
+ * threshold with two copies, and the copy a session reads is the one nobody
725
+ * updated — which is the failure #129 exists to end. So the brief describes
726
+ * the policy and names where to read it; it never restates it.
727
+ */
728
+ function policySourceLine(p: ProjectConfig): string {
729
+ return (
730
+ `They are in \`${configPath()}\` under \`projects[] "${p.name}" policy\`, and they change with ` +
731
+ `\`/conductor setup\` → "${AMEND_AREAS.policy.name}". Never by hand in this file.`
732
+ );
733
+ }
734
+
620
735
  /** Package floor template, placeholders and all. */
621
736
  export function shippedFloorTemplate(): string {
622
737
  return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8");
@@ -907,15 +1022,41 @@ export function summarisePlan(
907
1022
  );
908
1023
 
909
1024
  const delegated = a.authority.merge === "orchestrator" || a.authority.release === "orchestrator";
1025
+ // The consent screen is the last place an unintended grant can be caught, so
1026
+ // it names the shapes rather than a policy word: "operator-brief" is exactly
1027
+ // what the operator in #122 read as safe while it also permitted a deploy.
1028
+ const granted = RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] === "orchestrator");
910
1029
  lines.push(
911
1030
  "",
912
1031
  `authority merge=${a.authority.merge} release=${a.authority.release}`,
913
- `tool gate releasePolicy=${a.releasePolicy}`,
1032
+ `tool gate ${
1033
+ granted.length === 0
1034
+ ? "every release/deploy shape blocked for workers and the orchestrator"
1035
+ : `orchestrator may invoke ${granted.join(", ")}`
1036
+ }`,
1037
+ ...(granted.length === 0
1038
+ ? []
1039
+ : [
1040
+ ` blocked: ${RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] !== "orchestrator").join(", ") || "nothing"}`,
1041
+ ]),
914
1042
  delegated
915
1043
  ? " the brief tells that session so, and it must spell the procedure out before acting"
916
1044
  : " humans do both; workers and the conductor stop at a green PR",
917
1045
  );
918
1046
 
1047
+ lines.push("", "preconditions what a merge and a release must satisfy — checked mechanically, never re-read from prose");
1048
+ lines.push(
1049
+ ` merge ${describeChecks(a.policy.merge.requiredChecks, "the PR")}; base ${a.policy.merge.baseFreshness}; ` +
1050
+ `drafts ${a.policy.merge.drafts}; behind base → ${a.policy.merge.whenBehindBase}`,
1051
+ ` release ${
1052
+ a.policy.release.requires.length === 0
1053
+ ? "nothing has to have landed first"
1054
+ : `needs ${a.policy.release.requires.join(", ")}`
1055
+ }; ${describeChecks(a.policy.release.requiredChecks, "the branch")}`,
1056
+ ` artefacts ${a.policy.release.artefacts.join(", ") || "none declared — a release verb has nothing to ship"}`,
1057
+ ` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
1058
+ );
1059
+
919
1060
  const chosen = REPORT_SCOPE_CHOICES.find((c) => c.scope === a.reportScope);
920
1061
  const briefPath = orchestratorBriefPath(a);
921
1062
  lines.push(
@@ -948,6 +1089,16 @@ export function summarisePlan(
948
1089
  * one runs from a subdirectory. One spelling, so the pre-filled prompt line and
949
1090
  * the amend menu's current value cannot drift apart.
950
1091
  */
1092
+ /**
1093
+ * A required-check list as both the plan and the amend row say it. An empty list
1094
+ * is *stricter* than a named one, so it is spelled out rather than shown as
1095
+ * "none" — an operator reading "checks: none" would reasonably conclude the gate
1096
+ * was open.
1097
+ */
1098
+ function describeChecks(checks: readonly string[], reporter: string): string {
1099
+ return checks.length === 0 ? `every check ${reporter} reports` : `checks ${checks.join(", ")}`;
1100
+ }
1101
+
951
1102
  export function formatGates(gates: readonly { cmd: string; cwd: string }[]): string {
952
1103
  return gates.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
953
1104
  }
@@ -957,7 +1108,7 @@ export function formatGates(gates: readonly { cmd: string; cwd: string }[]): str
957
1108
  * order the full interview asks them.
958
1109
  *
959
1110
  * Data rather than a switch so the menu, the exhaustiveness of the dialog table
960
- * in ./plugin.ts, and the amend summary all enumerate the same eight areas: an
1111
+ * in ./plugin.ts, and the amend summary all enumerate the same nine areas: an
961
1112
  * added area fails to compile until it has a name, a current value and a set of
962
1113
  * questions.
963
1114
  */
@@ -967,6 +1118,8 @@ export const AMEND_AREA_IDS = [
967
1118
  "caps",
968
1119
  "graph",
969
1120
  "authority",
1121
+ "policy",
1122
+ "credentials",
970
1123
  "escalation",
971
1124
  "reporting",
972
1125
  "brief",
@@ -1042,9 +1195,45 @@ export const AMEND_AREAS: {
1042
1195
  },
1043
1196
  authority: {
1044
1197
  name: "authority",
1045
- asks: "who lands green PRs, who cuts releases, and whether release/deploy tools are mechanically open",
1046
- describe: (p) =>
1047
- `merge=${p.authority.merge}, release=${p.authority.release}, releasePolicy=${resolveReleasePolicy(p)}`,
1198
+ asks: "who lands green PRs, who cuts releases, then one question per release/deploy shape",
1199
+ describe: (p) => {
1200
+ const grants = resolveReleaseGrants(p);
1201
+ const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
1202
+ // Counted rather than listed because this row is elided at 96 characters —
1203
+ // and `deploy` is then named on its own, because it is the one grant in the
1204
+ // set that mutates a running environment and the one #122 cost us. The full
1205
+ // table is in `status` and in the amend summary.
1206
+ return (
1207
+ `merge=${p.authority.merge}, release=${p.authority.release}, tools: ` +
1208
+ `${granted.length === 0 ? "all blocked" : `${granted.length}/${RELEASE_SHAPES.length} granted`}` +
1209
+ `, deploy=${grants.deploy}`
1210
+ );
1211
+ },
1212
+ },
1213
+ policy: {
1214
+ name: "merge & release preconditions",
1215
+ asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships",
1216
+ describe: (p) => {
1217
+ const policy = resolvePolicy(p);
1218
+ // Counted rather than listed: this row is elided at 96 characters, and the
1219
+ // full table is in the plan summary the consent screen shows next.
1220
+ return (
1221
+ `merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
1222
+ `base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
1223
+ `release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
1224
+ `${policy.release.environments.length} env(s)`
1225
+ );
1226
+ },
1227
+ },
1228
+ credentials: {
1229
+ name: "credential isolation",
1230
+ asks: "whether worker and orchestrator sessions run under their own OS principal, or as the daemon's own user",
1231
+ describe: (p) => {
1232
+ const c = resolveCredentials(p);
1233
+ return c.isolation === "per-run"
1234
+ ? `per-run principals${c.readToken === undefined ? "" : ", with a read-scoped token"}`
1235
+ : "none — sessions run as the daemon's user and can reach its credentials";
1236
+ },
1048
1237
  },
1049
1238
  escalation: {
1050
1239
  name: "escalation & triage",
@@ -1107,7 +1296,7 @@ export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string
1107
1296
 
1108
1297
  /**
1109
1298
  * What an amend leads its consent screen with: the area, what it said, what it
1110
- * would say, and the seven areas nobody was asked about.
1299
+ * would say, and the eight areas nobody was asked about.
1111
1300
  *
1112
1301
  * The whole plan still follows this, because the confirm has to name every
1113
1302
  * mutation it authorises — creating labels, writing the config, replacing a