omp-conductor 0.15.10 → 0.15.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.
Files changed (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
package/src/setup.ts CHANGED
@@ -29,8 +29,10 @@ import {
29
29
  POLICY_BRIEF_NAME,
30
30
  composeOrchestrator,
31
31
  policyPathForRoot,
32
+ readSharedPolicy,
32
33
  refreshComposedBrief,
33
34
  renderBriefTemplate,
35
+ sharedPolicyPath,
34
36
  } from "./brief-upgrade.ts";
35
37
  import {
36
38
  clonePolicy,
@@ -55,6 +57,7 @@ import {
55
57
  WEEKDAYS,
56
58
  type BaseFreshness,
57
59
  type BehindBaseAction,
60
+ type AuthorityHolder,
58
61
  type Caps,
59
62
  type ConductorConfig,
60
63
  type DigestCadence,
@@ -152,6 +155,14 @@ export interface SetupAnswers {
152
155
  * survive an amend of some other area (#369).
153
156
  */
154
157
  groomBelow?: number;
158
+ /**
159
+ * Ordered fallback worker models and the provider-failure threshold that
160
+ * starts them. The wizard never asks for either — they are hand-edited —
161
+ * so they exist here only to survive an amend of some other area (#369,
162
+ * #286).
163
+ */
164
+ modelFallbacks?: string[];
165
+ modelFallbackThreshold?: number;
155
166
  telegramChatId?: string;
156
167
  /** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
157
168
  telegramTopicId?: number;
@@ -190,6 +201,13 @@ export interface SetupAnswers {
190
201
  * merge provenance gate.
191
202
  */
192
203
  recoveryMerges?: ProjectConfig["recoveryMerges"];
204
+ /**
205
+ * Hand-edited critical-base/safety markers (commit SHAs or refs) carried
206
+ * through setup unchanged, like {@link recoveryMerges}. The wizard never
207
+ * invents one; forgetting them during an unrelated amend would drop the
208
+ * stale-base interlock from a project that relies on it (#428).
209
+ */
210
+ criticalBase?: ProjectConfig["criticalBase"];
193
211
  /**
194
212
  * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
195
213
  * part of the config — the brief is the operator's file, and the conductor
@@ -411,6 +429,31 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
411
429
  " one is a hard boundary, not a preference.",
412
430
  };
413
431
 
432
+ /**
433
+ * The Promotion paragraph, worded from `authority.promotion`.
434
+ *
435
+ * The queue label is a sign-off: adding it to an issue lets a worker claim it
436
+ * and starts spend. POLICY.md must state who may do that — never a blank the
437
+ * floor defers into — and an unstated owner reads operator-only, because a
438
+ * gate nobody claimed must not resolve to the session that wants to use it.
439
+ *
440
+ * The paragraph refers to "the queue label" rather than naming it: the label
441
+ * itself is rendered into the section heading from config, and a second copy
442
+ * here would be a label that can disagree with the one the daemon reads.
443
+ */
444
+ export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = {
445
+ human:
446
+ "**Default: the operator promotes.** Adding the queue label to an issue is promotion, and\n" +
447
+ "promotion is the operator's: the orchestrator grooms, routes and proposes, but the label\n" +
448
+ "goes on only when the operator puts it there — it is the sign-off that starts spend. To\n" +
449
+ "delegate, re-run `omp-conductor setup` or replace this paragraph with one naming the\n" +
450
+ "orchestrator as promoter.",
451
+ orchestrator:
452
+ "**Delegated in setup: the orchestrator promotes.** Adding the queue label to an issue is\n" +
453
+ "promotion, and the orchestrator may do it after grooming the issue, one at a time. The\n" +
454
+ "operator can take it back by editing this paragraph.",
455
+ };
456
+
414
457
  export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
415
458
 
416
459
  /** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
@@ -427,12 +470,14 @@ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
427
470
  const REQUIRED_SCOPES = ["repo", "project"] as const;
428
471
 
429
472
  /** GitHub's own palette, so the tracker reads at a glance: green means queued,
430
- * blue means moving, amber means waiting on you, red means it gave up. */
473
+ * blue means moving, amber means waiting on you, red means it gave up, and
474
+ * purple is routing — the operator's input, the one label the loop never writes. */
431
475
  const LABEL_COLOURS = {
432
476
  queue: "0e8a16",
433
477
  inProgress: "1d76db",
434
478
  blocked: "fbca04",
435
479
  failed: "b60205",
480
+ routing: "6f42c1",
436
481
  } as const;
437
482
 
438
483
  interface GhResult {
@@ -514,11 +559,18 @@ export async function checkTokenScopes(): Promise<ScopeCheck> {
514
559
  }
515
560
 
516
561
  /**
517
- * The four labels the loop reads and writes, and which already exist. Read-only:
518
- * this is what the operator is shown before being asked to consent to creation.
562
+ * Every label setup wants on the tracker, before existence is known: the queue
563
+ * and state labels the loop reads and writes, plus one routing label per routed
564
+ * repo. Pure — the prefix and the repo keys are already answered at this point
565
+ * in the interview — which is what lets a test pin the whole set without `gh`.
566
+ *
567
+ * Routing labels are provisioned, never applied: `route()` requires exactly one
568
+ * `<prefix><repo>` label per issue and treats zero or two as unroutable, so a
569
+ * tracker without them can queue nothing. Creating them queues nothing either —
570
+ * applying one, with the queue label, stays the operator's sign-off.
519
571
  */
520
- export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
521
- const wanted: Omit<LabelPlan, "exists">[] = [
572
+ export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
573
+ return [
522
574
  {
523
575
  name: a.queueLabel,
524
576
  colour: LABEL_COLOURS.queue,
@@ -539,13 +591,28 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
539
591
  colour: LABEL_COLOURS.failed,
540
592
  description: "The conductor gave up on this issue after its retry budget",
541
593
  },
594
+ ...a.targetRepos.map((r) => ({
595
+ name: `${a.routingLabelPrefix}${r.name}`,
596
+ colour: LABEL_COLOURS.routing,
597
+ description: `Routes this issue to the ${r.name} checkout`,
598
+ })),
542
599
  ];
600
+ }
543
601
 
544
- const existing = await listLabelNames(trackerRepo);
545
-
546
- // GitHub label names are unique case-insensitively, so an operator who answers
547
- // "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
548
- // creation is pending that would then fail.
602
+ /**
603
+ * Turns wanted labels into the plan the operator is shown, deduped
604
+ * case-insensitively and marked against what already exists.
605
+ *
606
+ * GitHub label names are unique case-insensitively, so an operator who answers
607
+ * "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
608
+ * creation is pending that would then fail — and a multi-repo project whose
609
+ * names case-fold onto each other or onto a lifecycle label wants one creation,
610
+ * not two.
611
+ */
612
+ export function planAgainstLabels(
613
+ wanted: readonly Omit<LabelPlan, "exists">[],
614
+ existing: ReadonlySet<string>,
615
+ ): LabelPlan[] {
549
616
  const seen = new Set<string>();
550
617
  const plan: LabelPlan[] = [];
551
618
  for (const w of wanted) {
@@ -557,6 +624,18 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
557
624
  return plan;
558
625
  }
559
626
 
627
+ /**
628
+ * The labels a tracker needs, and which already exist. Read-only: this is what
629
+ * the operator is shown before being asked to consent to creation.
630
+ *
631
+ * The queue and state labels are the ones the loop reads and writes; the
632
+ * routing labels (one per repo) are the operator's input that makes an issue
633
+ * routable, and setup provisions them only so there is something to apply.
634
+ */
635
+ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
636
+ return planAgainstLabels(wantedLabels(a), await listLabelNames(trackerRepo));
637
+ }
638
+
560
639
  /** Lower-cased label names currently on the repo. Throws rather than guessing:
561
640
  * a repo whose labels cannot be listed cannot be serviced either. */
562
641
  async function listLabelNames(trackerRepo: string): Promise<Set<string>> {
@@ -772,6 +851,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
772
851
  : {}),
773
852
  // A documented hand-edited key, so an unrelated amend must not delete it.
774
853
  ...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }),
854
+ // The failover chain is hand-edited too: dropping it on an unrelated amend
855
+ // would pin every run of the project onto a dead provider again (#286).
856
+ ...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
857
+ ...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
775
858
  escalation,
776
859
  authority: { ...a.authority },
777
860
  // Written out in full, never as the legacy string: the file then says which
@@ -783,6 +866,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
783
866
  ...(a.recoveryMerges === undefined
784
867
  ? {}
785
868
  : { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
869
+ // Hand-edited safety markers carried unchanged on an unrelated amend
870
+ // (#428): dropping them would silently disarm the stale-base interlock.
871
+ ...(a.criticalBase === undefined ? {} : { criticalBase: [...a.criticalBase] }),
786
872
  // Written out even when it is the default, so an operator amending the
787
873
  // volume has a line in the file to point at. Opting into availability makes
788
874
  // the schedule explicit and daily; omitting it preserves the preset's
@@ -973,11 +1059,14 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
973
1059
  // what keeps the rewritten config identical to the one that was read.
974
1060
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
975
1061
  if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
1062
+ if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
1063
+ if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
976
1064
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
977
1065
  if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
978
1066
  if (p.recoveryMerges !== undefined) {
979
1067
  answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
980
1068
  }
1069
+ if (p.criticalBase !== undefined) answers.criticalBase = [...p.criticalBase];
981
1070
  if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
982
1071
  if (p.reporting?.digest.timezone !== undefined) {
983
1072
  answers.reportingTimezone = p.reporting.digest.timezone;
@@ -1023,6 +1112,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
1023
1112
  QUEUE_LABEL: p.queueLabel,
1024
1113
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
1025
1114
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
1115
+ PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion],
1026
1116
  POLICY_SOURCE: policySourceLine(p),
1027
1117
  };
1028
1118
  }
@@ -1073,14 +1163,19 @@ export function renderPolicyForProject(p: ProjectConfig): string {
1073
1163
  }
1074
1164
 
1075
1165
  /**
1076
- * Composed session brief: rendered floor + POLICY scaffold (or a caller's policy).
1166
+ * Composed session brief: rendered floor + shared policy (when present) +
1167
+ * POLICY scaffold (or a caller's policy).
1077
1168
  *
1078
1169
  * Setup writes the policy half to `POLICY.md` and this compose to
1079
- * `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md so package
1080
- * floor updates apply without brief-upgrade.
1170
+ * `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md and the live
1171
+ * host-wide shared policy, so package floor updates apply without brief-upgrade.
1081
1172
  */
1082
1173
  export function renderBriefForProject(p: ProjectConfig, policyText?: string): string {
1083
- return composeOrchestrator(renderFloorForProject(p), policyText ?? renderPolicyForProject(p));
1174
+ return composeOrchestrator(
1175
+ renderFloorForProject(p),
1176
+ policyText ?? renderPolicyForProject(p),
1177
+ readSharedPolicy(),
1178
+ );
1084
1179
  }
1085
1180
 
1086
1181
  /** Wizard-time path, via the project the answers describe. */
@@ -1094,7 +1189,7 @@ export function renderOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}
1094
1189
  // would write" and "what setup writes" cannot diverge — the preview an operator
1095
1190
  // consents to is the file they get.
1096
1191
  const project = buildProject(a);
1097
- return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose));
1192
+ return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose), readSharedPolicy());
1098
1193
  }
1099
1194
 
1100
1195
  /** `POLICY.md` for these answers: the project's own template plus the interview. */
@@ -1149,7 +1244,7 @@ export function writeOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {})
1149
1244
  mkdirSync(dirname(orchestratorPath), { recursive: true });
1150
1245
  const policy = renderPolicy(a, prose);
1151
1246
  writeFileSync(policyPath, policy);
1152
- writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
1247
+ writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy, readSharedPolicy()));
1153
1248
  return orchestratorPath;
1154
1249
  }
1155
1250
 
@@ -1246,7 +1341,8 @@ function releaseLines(j: OperatorJudgment): string[] {
1246
1341
  }
1247
1342
 
1248
1343
  /**
1249
- * Recompose `ORCHESTRATOR.md` from the package floor + live `POLICY.md`.
1344
+ * Recompose `ORCHESTRATOR.md` from the package floor + host-wide shared policy
1345
+ * (when present) + live `POLICY.md`.
1250
1346
  *
1251
1347
  * Returns false when POLICY.md is missing (caller should migrate or set up).
1252
1348
  */
@@ -1255,6 +1351,7 @@ export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
1255
1351
  policyPath: policyPathForProject(p),
1256
1352
  orchestratorPath: briefPathForProject(p),
1257
1353
  floor: renderFloorForProject(p),
1354
+ sharedPolicyPath: sharedPolicyPath(),
1258
1355
  });
1259
1356
  }
1260
1357
 
@@ -1355,6 +1452,15 @@ function describeAvailabilityDays(days: readonly Weekday[]): string {
1355
1452
  : days.join(",");
1356
1453
  }
1357
1454
 
1455
+ /**
1456
+ * The caps table's key column. Wide enough that the longest cap key,
1457
+ * `maxConcurrentWorkersPerRepo`, cannot fuse with its value — the very defect
1458
+ * that made #448's consent screen read `maxConcurrentWorkersPerRepo1` while
1459
+ * the daemon ran 3. Exported so tests pin the rendered table against the same
1460
+ * column the renderer uses.
1461
+ */
1462
+ export const CAPS_PLAN_KEY_PAD = 28;
1463
+
1358
1464
  /**
1359
1465
  * Everything that would change, as plain text, with no side effects at all.
1360
1466
  *
@@ -1368,11 +1474,15 @@ export function summarisePlan(
1368
1474
  scopes: ScopeCheck,
1369
1475
  labels: LabelPlan[],
1370
1476
  tg: TelegramPresence,
1477
+ defaults: Caps = DEFAULT_CAPS,
1371
1478
  ): string {
1372
1479
  const project = buildProject(a);
1373
- // Caps are shown against the shipped baseline: the summary describes what
1374
- // these answers mean on their own, before any hand-edited global block.
1375
- const effective = resolveCaps(project, DEFAULT_CAPS);
1480
+ // Caps are resolved against the caller's defaults — the loaded config's
1481
+ // global block on a re-run, the shipped baseline on a first run, where the
1482
+ // two are the same object. That is the exact baseline the daemon resolves
1483
+ // against, so the consent screen and runtime cannot disagree (#372/#448).
1484
+ // The shipped baseline itself is never labelled "effective".
1485
+ const effective = resolveCaps(project, defaults);
1376
1486
  const states = Object.values(a.stateLabels).join(", ");
1377
1487
 
1378
1488
  const lines: string[] = [];
@@ -1441,10 +1551,10 @@ export function summarisePlan(
1441
1551
  lines.push("", "caps (effective)");
1442
1552
  for (const [key, value] of Object.entries(effective)) {
1443
1553
  const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
1444
- lines.push(` ${key.padEnd(22)}${String(value)}${answered}`);
1554
+ lines.push(` ${key.padEnd(CAPS_PLAN_KEY_PAD)}${String(value)}${answered}`);
1445
1555
  }
1446
1556
  if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
1447
- lines.push(` ${"worker model".padEnd(22)}${a.workerModel.trim()} (answered)`);
1557
+ lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`);
1448
1558
  }
1449
1559
 
1450
1560
  lines.push("", "escalation");
@@ -1482,7 +1592,7 @@ export function summarisePlan(
1482
1592
  const granted = RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] === "orchestrator");
1483
1593
  lines.push(
1484
1594
  "",
1485
- `authority merge=${a.authority.merge} release=${a.authority.release}`,
1595
+ `authority merge=${a.authority.merge} release=${a.authority.release} promotion=${a.authority.promotion ?? DEFAULT_AUTHORITY.promotion}`,
1486
1596
  `tool gate ${
1487
1597
  granted.length === 0
1488
1598
  ? "every release/deploy shape blocked for workers and the orchestrator"
@@ -1602,13 +1712,16 @@ export type AmendAreaId = (typeof AMEND_AREA_IDS)[number];
1602
1712
  * `describe` is the reason the pick-list is worth anything — an operator picking
1603
1713
  * blind from eight nouns cannot tell which one holds the setting they came to
1604
1714
  * change, so every row carries its own current value. It reads only the config,
1605
- * so the whole menu can be rendered and reviewed without a terminal.
1715
+ * so the whole menu can be rendered and reviewed without a terminal. The caps
1716
+ * row resolves inherited values against the defaults the caller is amending —
1717
+ * the loaded config's global block on a re-run — so the menu and the daemon
1718
+ * agree on what a project actually runs (#448).
1606
1719
  */
1607
1720
  export const AMEND_AREAS: {
1608
1721
  readonly [K in AmendAreaId]: {
1609
1722
  readonly name: string;
1610
1723
  readonly asks: string;
1611
- readonly describe: (p: ProjectConfig) => string;
1724
+ readonly describe: (p: ProjectConfig, defaults?: Caps) => string;
1612
1725
  };
1613
1726
  } = {
1614
1727
  tracker: {
@@ -1636,8 +1749,8 @@ export const AMEND_AREAS: {
1636
1749
  // an area no menu offers is a setting only a full re-interview can reach.
1637
1750
  name: "caps & worker model",
1638
1751
  asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
1639
- describe: (p) => {
1640
- const c = resolveCaps(p, DEFAULT_CAPS);
1752
+ describe: (p, defaults = DEFAULT_CAPS) => {
1753
+ const c = resolveCaps(p, defaults);
1641
1754
  const answered = Object.keys(p.caps).length > 0;
1642
1755
  const spend =
1643
1756
  c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
@@ -1664,7 +1777,7 @@ export const AMEND_AREAS: {
1664
1777
  },
1665
1778
  authority: {
1666
1779
  name: "authority",
1667
- asks: "who lands green PRs, who cuts releases, then one question per release/deploy shape",
1780
+ asks: "who lands green PRs, who cuts releases, who promotes, then one question per release/deploy shape",
1668
1781
  describe: (p) => {
1669
1782
  const grants = resolveReleaseGrants(p);
1670
1783
  const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
@@ -1673,7 +1786,8 @@ export const AMEND_AREAS: {
1673
1786
  // set that mutates a running environment and the one #122 cost us. The full
1674
1787
  // table is in `status` and in the amend summary.
1675
1788
  return (
1676
- `merge=${p.authority.merge}, release=${p.authority.release}, tools: ` +
1789
+ `merge=${p.authority.merge}, release=${p.authority.release}, ` +
1790
+ `promote=${p.authority.promotion ?? DEFAULT_AUTHORITY.promotion}, tools: ` +
1677
1791
  `${granted.length === 0 ? "all blocked" : `${granted.length}/${RELEASE_SHAPES.length} granted`}` +
1678
1792
  `, deploy=${grants.deploy}`
1679
1793
  );
@@ -1742,10 +1856,10 @@ const AMEND_LABEL_MAX = 96;
1742
1856
  * its own text alone — and it is the value, not the noun, that tells them
1743
1857
  * whether this is the row they came for.
1744
1858
  */
1745
- export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string; description: string }[] {
1859
+ export function amendChoices(p: ProjectConfig, defaults: Caps = DEFAULT_CAPS): { id: AmendAreaId; label: string; description: string }[] {
1746
1860
  return AMEND_AREA_IDS.map((id) => {
1747
1861
  const area = AMEND_AREAS[id];
1748
- const current = area.describe(p);
1862
+ const current = area.describe(p, defaults);
1749
1863
  return {
1750
1864
  id,
1751
1865
  label: `${area.name} — ${current.length > AMEND_LABEL_MAX ? `${current.slice(0, AMEND_LABEL_MAX - 1).trimEnd()}…` : current}`,
@@ -1762,11 +1876,18 @@ export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string
1762
1876
  * mutation it authorises — creating labels, writing the config, replacing a
1763
1877
  * brief — and a delta alone names none of them. What this adds is the sentence
1764
1878
  * the operator is actually looking for: one area changed, everything else came
1765
- * back off disk.
1879
+ * back off disk. Inherited caps on both sides of the "was"/"now" compare
1880
+ * against the same `defaults`, so a changed per-repo limit reads as a change
1881
+ * and a carried value reads as no change (#448).
1766
1882
  */
1767
- export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: SetupAnswers): string {
1883
+ export function summariseAmend(
1884
+ area: AmendAreaId,
1885
+ before: ProjectConfig,
1886
+ a: SetupAnswers,
1887
+ defaults: Caps = DEFAULT_CAPS,
1888
+ ): string {
1768
1889
  const it = AMEND_AREAS[area];
1769
- const was = it.describe(before);
1890
+ const was = it.describe(before, defaults);
1770
1891
  // The brief is a decision, not a config key, so its "after" is what the wizard
1771
1892
  // is about to do rather than what a rebuilt project would say.
1772
1893
  const now =
@@ -1774,7 +1895,7 @@ export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: Setu
1774
1895
  ? a.writeOrchestratorBrief
1775
1896
  ? `would ${existsSync(orchestratorBriefPath(a)) ? "OVERWRITE" : "write"} ${orchestratorBriefPath(a)}`
1776
1897
  : "not written — left exactly as it is"
1777
- : it.describe(buildProject(a));
1898
+ : it.describe(buildProject(a), defaults);
1778
1899
 
1779
1900
  const others = AMEND_AREA_IDS.filter((o) => o !== area).map((o) => AMEND_AREAS[o].name);
1780
1901
  const lines = [`amending ${it.name} — project ${before.name}`];