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