omp-conductor 0.13.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.
Files changed (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
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
@@ -52,22 +52,73 @@ import {
52
52
  DEFAULT_REPORT_SCOPE,
53
53
  DENIED_RELEASE_GRANTS,
54
54
  RELEASE_SHAPES,
55
+ WEEKDAYS,
55
56
  type BaseFreshness,
56
57
  type BehindBaseAction,
57
58
  type Caps,
58
59
  type ConductorConfig,
60
+ type DigestCadence,
59
61
  type DraftPolicy,
60
62
  type OrchestratorMode,
63
+ type InterruptCategory,
61
64
  type ProjectConfig,
62
65
  type ProjectPolicy,
63
66
  type ReleaseRequirement,
64
- type ReportScope,
65
67
  type ReportScopeChoice,
66
68
  type ReportingPolicy,
69
+ type Weekday,
70
+ type WeeklyAvailability,
67
71
  type RepoTarget,
68
72
  type ResolvedGrants,
69
73
  } from "./types.ts";
70
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
+
71
122
  /**
72
123
  * Every decision the wizard needs, in one plain object. Collected by the UI,
73
124
  * consumed by `buildConfig`, so the prompt order can change without touching
@@ -89,11 +140,24 @@ export interface SetupAnswers {
89
140
  */
90
141
  workerModel?: string;
91
142
  telegramChatId?: string;
143
+ /** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
144
+ telegramTopicId?: number;
92
145
  fallbackToIssueComment: boolean;
93
146
  /** How loud the supervising orchestrator session should be. */
94
147
  reportScope: ReportScopeChoice;
95
- /** The daily digest wall-clock (`HH:MM`) for the `quiet` choice, blank = model-timed. */
96
- quietDigestAt?: string;
148
+ /** One IANA zone shared by the configured window and its daily digest. */
149
+ reportingTimezone?: string;
150
+ /** Daily digest wall-clock (`HH:MM`) when an explicit daily policy is chosen. */
151
+ dailyDigestAt?: string;
152
+ /** Digest cadence when preserving or explicitly editing a reporting policy. */
153
+ digestCadence?: DigestCadence;
154
+ /** Weekly window without its shared timezone; absent keeps 24-hour interrupts. */
155
+ availability?: Omit<WeeklyAvailability, "timezone">;
156
+ /**
157
+ * Exact policy read from disk. Unrelated amend areas carry this through
158
+ * untouched; the reporting interview deletes it before applying new answers.
159
+ */
160
+ preservedReporting?: ReportingPolicy;
97
161
  /**
98
162
  * Mechanical gate for release/deploy-shaped tool calls, per shape. Always
99
163
  * complete: the wizard asks about every shape, so an answer object can never
@@ -113,6 +177,17 @@ export interface SetupAnswers {
113
177
  * prompt that asked it to the step that acts on it.
114
178
  */
115
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;
116
191
  /**
117
192
  * Who lands green PRs and who cuts releases. Both default to the human — see
118
193
  * {@link DEFAULT_AUTHORITY} — and the answer is what the rendered brief's
@@ -138,6 +213,16 @@ export interface SetupAnswers {
138
213
  * the same question four times to arrive at four siblings.
139
214
  */
140
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
+
141
226
  }
142
227
 
143
228
  /** What `gh auth status` says the active token may do. */
@@ -189,7 +274,7 @@ export const REPORT_SCOPE_CHOICES: readonly {
189
274
  scope: "decisions",
190
275
  label: "Decisions interrupt, rest batches",
191
276
  description:
192
- "tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the tick report",
277
+ "tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the configured digest",
193
278
  },
194
279
  {
195
280
  scope: "material",
@@ -510,6 +595,88 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
510
595
  return created;
511
596
  }
512
597
 
598
+ const QUIET_INTERRUPT_ON: InterruptCategory[] = [
599
+ "tier2",
600
+ "fleet-stopped",
601
+ "confirmed-failure",
602
+ ];
603
+
604
+ function interruptCategories(scope: ReportScopeChoice): InterruptCategory[] {
605
+ return scope === "quiet"
606
+ ? [...QUIET_INTERRUPT_ON]
607
+ : [...(SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn];
608
+ }
609
+
610
+ /** Copy policy arrays so a setup amend cannot mutate the config it was seeded from. */
611
+ function cloneReportingPolicy(policy: ReportingPolicy): ReportingPolicy {
612
+ return {
613
+ interruptOn: [...policy.interruptOn],
614
+ digest: { ...policy.digest },
615
+ ...(policy.availability === undefined
616
+ ? {}
617
+ : {
618
+ availability: {
619
+ ...policy.availability,
620
+ days: [...policy.availability.days],
621
+ bypass: [...policy.availability.bypass],
622
+ },
623
+ }),
624
+ ...(policy.scopePreset === undefined ? {} : { scopePreset: policy.scopePreset }),
625
+ };
626
+ }
627
+
628
+ /** Turn the wizard's local notification answers into the runtime policy. */
629
+ function reportingFromAnswers(a: SetupAnswers): ReportingPolicy {
630
+ if (a.preservedReporting !== undefined) return cloneReportingPolicy(a.preservedReporting);
631
+
632
+ const digest = (fallback: DigestCadence): ReportingPolicy["digest"] => {
633
+ const cadence = a.digestCadence ?? fallback;
634
+ if (cadence !== "daily") return { cadence };
635
+ const at = a.dailyDigestAt?.trim();
636
+ const timezone = a.reportingTimezone?.trim();
637
+ return {
638
+ cadence,
639
+ ...(at === undefined || at.length === 0 ? {} : { at }),
640
+ ...(timezone === undefined || timezone.length === 0 ? {} : { timezone }),
641
+ };
642
+ };
643
+
644
+ const timezone = a.reportingTimezone?.trim();
645
+ if (a.availability !== undefined) {
646
+ if (timezone === undefined || timezone.length === 0) {
647
+ throw new Error("operator availability requires an IANA reporting timezone");
648
+ }
649
+ return {
650
+ interruptOn: interruptCategories(a.reportScope),
651
+ digest: digest("daily"),
652
+ availability: {
653
+ timezone,
654
+ days: [...a.availability.days],
655
+ start: a.availability.start,
656
+ end: a.availability.end,
657
+ bypass: [...a.availability.bypass],
658
+ },
659
+ };
660
+ }
661
+ if (a.reportScope === "quiet") {
662
+ return {
663
+ interruptOn: [...QUIET_INTERRUPT_ON],
664
+ digest: digest("daily"),
665
+ };
666
+ }
667
+ const preset = SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
668
+ const resolvedDigest = digest(preset.digest.cadence);
669
+ const presetDigest =
670
+ resolvedDigest.cadence === preset.digest.cadence &&
671
+ resolvedDigest.at === preset.digest.at &&
672
+ resolvedDigest.timezone === preset.digest.timezone;
673
+ return {
674
+ interruptOn: [...preset.interruptOn],
675
+ digest: resolvedDigest,
676
+ ...(presetDigest ? { scopePreset: preset.scopePreset } : {}),
677
+ };
678
+ }
679
+
513
680
  /**
514
681
  * The single `ProjectConfig` the answers describe.
515
682
  *
@@ -545,6 +712,13 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
545
712
  if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
546
713
  escalation.telegramChatId = a.telegramChatId.trim();
547
714
  }
715
+ if (
716
+ a.telegramTopicId !== undefined &&
717
+ Number.isFinite(a.telegramTopicId) &&
718
+ Number.isSafeInteger(a.telegramTopicId)
719
+ ) {
720
+ escalation.telegramTopicId = a.telegramTopicId;
721
+ }
548
722
 
549
723
  const caps: Partial<Caps> = {};
550
724
  // Drops `undefined` members so an unanswered cap is absent from the JSON
@@ -576,28 +750,16 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
576
750
  // and a release require without anyone having to know a default (#129).
577
751
  policy: clonePolicy(a.policy),
578
752
  // Written out even when it is the default, so an operator amending the
579
- // volume has a line in the file to point at. A `scope` answer is
580
- // materialised as its preset policy (with `scopePreset` set) so the file
581
- // and the prompt agree with no migration rule; `quiet` is the explicit
582
- // form — a curated interrupt list and a scheduled digest (#229).
583
- reporting:
584
- a.reportScope === "quiet"
585
- ? {
586
- interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
587
- digest: {
588
- cadence: "daily",
589
- ...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
590
- },
591
- }
592
- : {
593
- interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
594
- digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
595
- scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
596
- },
753
+ // volume has a line in the file to point at. Opting into availability makes
754
+ // the schedule explicit and daily; omitting it preserves the preset's
755
+ // legacy 24-hour behaviour (#273).
756
+ reporting: reportingFromAnswers(a),
597
757
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
598
758
  // uninstall, and neither can land in a repo the daemon then tries to commit.
599
- workspaceRoot: defaultWorkspaceRoot(),
600
- 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(),
601
763
  };
602
764
  }
603
765
 
@@ -612,6 +774,16 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
612
774
  export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): ConductorConfig {
613
775
  const project = buildProject(a);
614
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
+ }
615
787
 
616
788
  return {
617
789
  version: CONFIG_VERSION,
@@ -636,7 +808,14 @@ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): Conduc
636
808
  * default at each prompt, which is how one prompt comes to disagree with the
637
809
  * config key it writes.
638
810
  */
639
- 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;
640
819
  return {
641
820
  projectName,
642
821
  // Empty rather than a plausible guess: both are required, and a pre-filled
@@ -654,6 +833,12 @@ export function defaultAnswers(projectName: string): SetupAnswers {
654
833
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
655
834
  reportScope: SETUP_DEFAULT_REPORT_SCOPE,
656
835
  writeOrchestratorBrief: false,
836
+ ...(scopedBase === undefined
837
+ ? {}
838
+ : {
839
+ workspaceRoot: defaultWorkspaceRoot(scopedBase),
840
+ mirrorRoot: defaultMirrorRoot(scopedBase),
841
+ }),
657
842
  };
658
843
  }
659
844
 
@@ -667,19 +852,60 @@ export function defaultAnswers(projectName: string): SetupAnswers {
667
852
  * changing an unrelated one. `buildProject(answersFromProject(p))` is pinned to
668
853
  * `p` by a test for exactly that reason.
669
854
  *
670
- * Three fields cannot be a straight copy:
855
+ * Four fields cannot be a straight copy:
671
856
  *
672
857
  * - `writeOrchestratorBrief` is a decision rather than a value, and it starts
673
858
  * `false` so an amend that never visits the brief area leaves that file alone.
674
- * - `reportScope` reads through {@link DEFAULT_REPORT_SCOPE}, because the key is
675
- * optional on disk. A config written before it existed gains it explicitly on
676
- * the next write, saying what it already meant.
859
+ * - `reportScope` recognizes both preset metadata and the explicit quiet
860
+ * category set, because an availability schedule intentionally has no preset.
861
+ * - The reporting timezone is shared by availability and the daily digest in
862
+ * the wizard even though runtime config stores it in both places.
677
863
  * - `graphRoot` is one answer for a whole project while the config stores one
678
864
  * path per repo, so it comes back from whichever repo already has one. Repos
679
865
  * that disagree — only a hand-edit can produce that — widen to all of them on
680
866
  * the next write exactly as a full re-run would, and the plan summary names
681
867
  * every clone before anything is written.
682
868
  */
869
+ function sameInterruptCategories(
870
+ actual: readonly InterruptCategory[],
871
+ expected: readonly InterruptCategory[],
872
+ ): boolean {
873
+ return (
874
+ actual.length === expected.length &&
875
+ actual.every((category) => expected.includes(category))
876
+ );
877
+ }
878
+
879
+ export function reportScopeFromPolicy(policy: ReportingPolicy | undefined): ReportScopeChoice {
880
+ if (policy?.scopePreset !== undefined) return policy.scopePreset;
881
+ const actual = policy?.interruptOn ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].interruptOn;
882
+ if (sameInterruptCategories(actual, QUIET_INTERRUPT_ON)) return "quiet";
883
+ for (const scope of ["material", "decisions", "escalations"] as const) {
884
+ if (sameInterruptCategories(actual, SCOPE_PRESETS[scope].interruptOn)) return scope;
885
+ }
886
+ return DEFAULT_REPORT_SCOPE;
887
+ }
888
+
889
+ function reportScopeDescriptionForPolicy(policy: ReportingPolicy | undefined): string {
890
+ const scope = reportScopeFromPolicy(policy);
891
+ const choice = REPORT_SCOPE_CHOICES.find((candidate) => candidate.scope === scope);
892
+ const expectedCadence =
893
+ scope === "quiet" ? "daily" : SCOPE_PRESETS[scope].digest.cadence;
894
+ const configured = policy?.digest ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].digest;
895
+ if (configured.cadence === expectedCadence) {
896
+ return choice?.description ?? "unknown scope";
897
+ }
898
+ const digest =
899
+ configured.cadence === "none"
900
+ ? "disabled"
901
+ : configured.cadence === "per-tick"
902
+ ? "every tick"
903
+ : `daily at ${configured.at ?? "(model-timed)"}${
904
+ configured.timezone === undefined ? "" : ` ${configured.timezone}`
905
+ }`;
906
+ return `${choice?.label ?? scope}; configured digest ${digest}`;
907
+ }
908
+
683
909
  export function answersFromProject(p: ProjectConfig): SetupAnswers {
684
910
  const answers: SetupAnswers = {
685
911
  projectName: p.name,
@@ -699,16 +925,37 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
699
925
  releaseGrants: resolveReleaseGrants(p),
700
926
  policy: resolvePolicy(p),
701
927
  orchestratorMode: p.escalation.orchestrator,
702
- reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
928
+ reportScope: reportScopeFromPolicy(p.reporting),
703
929
  writeOrchestratorBrief: false,
704
930
  };
931
+ if (p.reporting !== undefined) {
932
+ answers.preservedReporting = cloneReportingPolicy(p.reporting);
933
+ answers.digestCadence = p.reporting.digest.cadence;
934
+ }
705
935
 
706
936
  // Set only when present, never as an explicit `undefined`: an absent key is
707
937
  // what keeps the rewritten config identical to the one that was read.
708
938
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
709
939
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
940
+ if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
941
+ if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
942
+ if (p.reporting?.digest.timezone !== undefined) {
943
+ answers.reportingTimezone = p.reporting.digest.timezone;
944
+ }
945
+ if (p.reporting?.availability !== undefined) {
946
+ answers.reportingTimezone = p.reporting.availability.timezone;
947
+ answers.availability = {
948
+ days: [...p.reporting.availability.days],
949
+ start: p.reporting.availability.start,
950
+ end: p.reporting.availability.end,
951
+ bypass: [...p.reporting.availability.bypass],
952
+ };
953
+ }
710
954
  const graphed = graphRepos(p)[0];
711
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
+
712
959
 
713
960
  return answers;
714
961
  }
@@ -736,7 +983,6 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
736
983
  QUEUE_LABEL: p.queueLabel,
737
984
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
738
985
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
739
- REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
740
986
  POLICY_SOURCE: policySourceLine(p),
741
987
  };
742
988
  }
@@ -754,7 +1000,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
754
1000
  function policySourceLine(p: ProjectConfig): string {
755
1001
  return (
756
1002
  `They are in \`${configPath()}\` under \`projects[] "${p.name}" policy\`, and they change with ` +
757
- `\`/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.`
758
1004
  );
759
1005
  }
760
1006
 
@@ -803,8 +1049,48 @@ export function orchestratorBriefPath(a: SetupAnswers): string {
803
1049
  }
804
1050
 
805
1051
  /** Wizard-time render, via the project the answers describe. */
806
- export function renderOrchestratorBrief(a: SetupAnswers): string {
807
- 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");
808
1094
  }
809
1095
 
810
1096
  /**
@@ -816,17 +1102,109 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
816
1102
  * that dialog's answer un-actionable — an operator who says "yes, overwrite it"
817
1103
  * must get an overwrite.
818
1104
  */
819
- export function writeOrchestratorBrief(a: SetupAnswers): string {
1105
+ export function writeOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}): string {
820
1106
  const project = buildProject(a);
821
1107
  const policyPath = policyPathForProject(project);
822
1108
  const orchestratorPath = briefPathForProject(project);
823
1109
  mkdirSync(dirname(orchestratorPath), { recursive: true });
824
- const policy = renderPolicyForProject(project);
1110
+ const policy = renderPolicy(a, prose);
825
1111
  writeFileSync(policyPath, policy);
826
1112
  writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
827
1113
  return orchestratorPath;
828
1114
  }
829
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
+
830
1208
  /**
831
1209
  * Recompose `ORCHESTRATOR.md` from the package floor + live `POLICY.md`.
832
1210
  *
@@ -931,6 +1309,12 @@ function pairedOwner(accessPath: string): string | undefined {
931
1309
  return undefined;
932
1310
  }
933
1311
 
1312
+ function describeAvailabilityDays(days: readonly Weekday[]): string {
1313
+ return days.length === WEEKDAYS.length && WEEKDAYS.every((day) => days.includes(day))
1314
+ ? "every day"
1315
+ : days.join(",");
1316
+ }
1317
+
934
1318
  /**
935
1319
  * Everything that would change, as plain text, with no side effects at all.
936
1320
  *
@@ -1009,8 +1393,8 @@ export function summarisePlan(
1009
1393
  for (const r of graphed) lines.push(` ${r.name} ${r.graphProject}`);
1010
1394
  lines.push(
1011
1395
  " conductor's own index-only clones — nothing human edits them, and",
1012
- " nothing here creates them. Run `omp-conductor graph-setup` after",
1013
- " 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.",
1014
1398
  );
1015
1399
  }
1016
1400
 
@@ -1025,8 +1409,12 @@ export function summarisePlan(
1025
1409
 
1026
1410
  lines.push("", "escalation");
1027
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";
1028
1416
  lines.push(
1029
- ` tier 2 Telegram chat ${a.telegramChatId.trim()}` +
1417
+ ` tier 2 Telegram chat ${a.telegramChatId.trim()}${topic}` +
1030
1418
  (tg.hasToken ? "" : " — WARNING: no bot token found, this will not send"),
1031
1419
  ` via omp-telegram at ${tg.stateDir}`,
1032
1420
  );
@@ -1083,12 +1471,27 @@ export function summarisePlan(
1083
1471
  ` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
1084
1472
  );
1085
1473
 
1086
- const chosen = REPORT_SCOPE_CHOICES.find((c) => c.scope === a.reportScope);
1474
+ const reporting = project.reporting as ReportingPolicy;
1087
1475
  const briefPath = orchestratorBriefPath(a);
1088
1476
  lines.push(
1089
1477
  "",
1090
1478
  "reporting",
1091
- ` scope ${a.reportScope} — ${chosen?.description ?? "unknown scope"}`,
1479
+ ` scope ${a.reportScope} — ${reportScopeDescriptionForPolicy(reporting)}`,
1480
+ );
1481
+ if (reporting.availability === undefined) {
1482
+ lines.push(" availability continuous — interrupt categories may page 24 hours a day");
1483
+ } else {
1484
+ lines.push(
1485
+ ` availability ${describeAvailabilityDays(reporting.availability.days)} ` +
1486
+ `${reporting.availability.start}-${reporting.availability.end} ${reporting.availability.timezone}`,
1487
+ ` bypass ${reporting.availability.bypass.join(", ") || "none"}`,
1488
+ );
1489
+ }
1490
+ lines.push(
1491
+ ` digest ${reporting.digest.cadence}` +
1492
+ (reporting.digest.cadence === "daily"
1493
+ ? ` at ${reporting.digest.at ?? "(model-timed)"}${reporting.digest.timezone ? ` ${reporting.digest.timezone}` : ""}`
1494
+ : ""),
1092
1495
  );
1093
1496
  if (a.writeOrchestratorBrief) {
1094
1497
  const policyPath = briefPath.replace(/ORCHESTRATOR\.md$/, "POLICY.md");
@@ -1133,16 +1536,16 @@ export function formatGates(gates: readonly { cmd: string; cwd: string }[]): str
1133
1536
  * The wizard's questions, grouped as the areas a re-run can amend one of, in the
1134
1537
  * order the full interview asks them.
1135
1538
  *
1136
- * Data rather than a switch so the menu, the exhaustiveness of the dialog table
1137
- * in ./plugin.ts, and the amend summary all enumerate the same nine areas: an
1138
- * added area fails to compile until it has a name, a current value and a set of
1139
- * 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.
1140
1543
  */
1141
1544
  export const AMEND_AREA_IDS = [
1142
1545
  "tracker",
1143
1546
  "gates",
1144
1547
  "caps",
1145
- "graph",
1548
+ "code-graph",
1146
1549
  "authority",
1147
1550
  "policy",
1148
1551
  "escalation",
@@ -1209,7 +1612,7 @@ export const AMEND_AREAS: {
1209
1612
  );
1210
1613
  },
1211
1614
  },
1212
- graph: {
1615
+ "code-graph": {
1213
1616
  name: "code graph",
1214
1617
  asks: "whether workers query a code-graph index, and the root its one-clone-per-repo lives under",
1215
1618
  describe: (p) => {
@@ -1253,12 +1656,14 @@ export const AMEND_AREAS: {
1253
1656
  },
1254
1657
  escalation: {
1255
1658
  name: "escalation & triage",
1256
- 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",
1257
1660
  describe: (p) =>
1258
1661
  [
1259
1662
  p.escalation.telegramChatId === undefined
1260
1663
  ? "tier 2 by issue comment only"
1261
- : `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}`,
1262
1667
  p.escalation.fallbackToIssueComment ? "comments too" : "no comment fallback",
1263
1668
  `triage ${p.escalation.orchestrator}`,
1264
1669
  ].join(", "),
@@ -1267,9 +1672,8 @@ export const AMEND_AREAS: {
1267
1672
  name: "reporting scope",
1268
1673
  asks: "how much the orchestrator says unprompted",
1269
1674
  describe: (p) => {
1270
- const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
1271
- const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
1272
- return `${scope} — ${choice?.description ?? "unknown scope"}`;
1675
+ const scope = reportScopeFromPolicy(p.reporting);
1676
+ return `${scope} — ${reportScopeDescriptionForPolicy(p.reporting)}`;
1273
1677
  },
1274
1678
  },
1275
1679
  brief: {