omp-conductor 0.2.0 → 0.2.1

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 CHANGED
@@ -212,6 +212,48 @@ turned on you can also invoke it directly:
212
212
  Nothing about the wizard changes: `/conductor setup` on its own remains a
213
213
  complete, supported path, and the brief it renders is safe unedited.
214
214
 
215
+ ### Keeping a brief current
216
+
217
+ Upgrading the package does not upgrade a brief you are already running, and it is
218
+ worth knowing exactly which half of that sentence is true.
219
+
220
+ | What | Updates on `omp plugin install`? |
221
+ | --- | --- |
222
+ | `skills/conductor-onboarding/SKILL.md` | Yes. The session reads it from the installed package. |
223
+ | `src/briefs/worker.md` | Yes. It is read per run, so the next worker gets the new text. |
224
+ | `src/briefs/orchestrator.md` | Yes, but it is only a *template*: it is read when the wizard renders a brief. |
225
+ | Your rendered `ORCHESTRATOR.md` | **No.** It was written once and is yours from then on. |
226
+
227
+ That last row is the point. Once the wizard renders your brief, nothing in this
228
+ package reads it back or rewrites it, so a later version that ships a new protocol
229
+ above the `YOURS TO EDIT` banner is invisible to every fleet already running:
230
+
231
+ ```bash
232
+ omp-conductor brief-upgrade # report only
233
+ omp-conductor brief-upgrade --apply # replace the shipped half, keep yours
234
+ ```
235
+
236
+ The banner is what makes this safe. Everything above it belongs to the package and
237
+ everything below it belongs to you, so an upgrade replaces the first and copies the
238
+ second across untouched, keeping the previous file as
239
+ `ORCHESTRATOR.md.bak-<timestamp>`. Three cases where it will not write at all:
240
+
241
+ - **Your brief has no banner** (hand-written, or predating the split). There is no
242
+ way to tell which lines are yours, so it lists the sections the template has and
243
+ yours does not, and leaves the file alone. Retitled sections count as present, so
244
+ `## Reporting (low noise)` is not reported as a missing `## Reporting`.
245
+ - **No config resolved**, so the template still carries its `{{PLACEHOLDER}}`
246
+ coordinates. Merging it would write those literals into a live prompt.
247
+ - **Nothing changed.** It says so and exits.
248
+
249
+ `--file PATH` checks a brief that is not where the wizard would have put it, which
250
+ is the normal case on a dedicated fleet host: the supervising session runs from its
251
+ own directory, and that host may never have configured a dispatch daemon.
252
+
253
+ A brief with the **Learning loop** section has a second route. The session running
254
+ from it can propose the missing sections itself, as a diff, for you to approve with
255
+ a yes over Telegram, which is the same protocol it uses for any other amendment.
256
+
215
257
  ## Quick start
216
258
 
217
259
  1. Write a config (see [Configuration](#configuration)) at
@@ -620,6 +662,7 @@ omp-conductor status [--project NAME]
620
662
  omp-conductor daemon [--once] [--port N] [--project NAME]
621
663
  omp-conductor pause
622
664
  omp-conductor resume
665
+ omp-conductor brief-upgrade [--apply] [--file PATH] [--project NAME]
623
666
  omp-conductor help
624
667
  ```
625
668
 
@@ -635,6 +678,9 @@ omp-conductor help
635
678
  | `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
636
679
  | `pause` | Stop claiming new work. The running daemon notices on its next tick; runs already in flight finish. |
637
680
  | `resume` | Allow claiming again. |
681
+ | `brief-upgrade` | Compare a project's `ORCHESTRATOR.md` against the brief this version of the package ships. Reports by default; see [Keeping a brief current](#keeping-a-brief-current). |
682
+ | `--apply` | Only for `brief-upgrade`. Replaces the half above the `YOURS TO EDIT` banner and keeps everything below it, backing the previous file up first. Ignored when the brief cannot be split or the template is unrendered. |
683
+ | `--file PATH` | Only for `brief-upgrade`. Check a brief that is not where the wizard would have put it, on a host that may have no config at all. |
638
684
  | `help`, `--help`, `-h` | Print usage. An unknown or missing verb prints it too, and exits `2`. |
639
685
 
640
686
  Pause is a flag file under the state directory, so it applies to every project and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Keeping a live `ORCHESTRATOR.md` current with the shipped template.
3
+ *
4
+ * The wizard renders the template once and then never touches the file again,
5
+ * because from that moment it is the operator's. That is the right ownership
6
+ * rule and it has one consequence nobody notices until months later: every
7
+ * later improvement to the *shipped* half of the brief — a new duty, a protocol
8
+ * like the amendment loop — is invisible to every fleet already running. The
9
+ * package updates; the standing prompt does not.
10
+ *
11
+ * This module is the missing half of that story. It never guesses: the brief has
12
+ * an explicit banner separating the package's text from the operator's, so when
13
+ * that banner is present the split is exact and the merge is mechanical. When it
14
+ * is absent — a hand-written brief, or one predating the split — there is no
15
+ * honest way to know which lines are the operator's, so nothing is rewritten and
16
+ * the missing sections are reported instead.
17
+ */
18
+
19
+ import { readFileSync, writeFileSync } from "node:fs";
20
+
21
+ /**
22
+ * The line that divides the two halves. Matched on this substring rather than
23
+ * the whole comment banner so a reflowed or re-decorated banner still splits.
24
+ */
25
+ const EDIT_BANNER = "YOURS TO EDIT";
26
+
27
+ /**
28
+ * A brief split into the package's half and the operator's half.
29
+ *
30
+ * `shipped` runs to the end of the banner line; `owned` is everything after it.
31
+ * Concatenating them reproduces the input byte for byte, which is what makes a
32
+ * merge safe to write back.
33
+ */
34
+ export interface BriefHalves {
35
+ shipped: string;
36
+ owned: string;
37
+ }
38
+
39
+ /**
40
+ * Splits on the banner, or returns `undefined` when there is none.
41
+ *
42
+ * `undefined` is a real answer, not a failure: it means this brief cannot be
43
+ * merged mechanically, and every caller is expected to degrade to reporting
44
+ * rather than to assume a boundary.
45
+ */
46
+ export function splitBrief(text: string): BriefHalves | undefined {
47
+ const at = text.indexOf(EDIT_BANNER);
48
+ if (at < 0) return undefined;
49
+ // Keep the whole banner line on the shipped side: the operator's half starts
50
+ // at the first line they own, so a merge never has to reconstruct the banner.
51
+ const lineEnd = text.indexOf("\n", at);
52
+ const cut = lineEnd < 0 ? text.length : lineEnd + 1;
53
+ return { shipped: text.slice(0, cut), owned: text.slice(cut) };
54
+ }
55
+
56
+ /** A `## Heading` in the shipped half, by its exact text. */
57
+ function headings(text: string): string[] {
58
+ const out: string[] = [];
59
+ for (const line of text.split("\n")) {
60
+ if (line.startsWith("## ")) out.push(line.slice(3).trim());
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * The comparable part of a heading: everything before the first dash, colon or
67
+ * bracket, lowercased.
68
+ *
69
+ * Operators retitle sections freely — `## Reporting` becomes `## Reporting (low
70
+ * noise, evidence-backed)`, `## Duty 1 — drain` becomes `## Duty 1 — the dispatch
71
+ * loop (run this on every tick)` — and an exact match would report all of those as
72
+ * absent. Ten reported sections when four are genuinely missing is a list nobody
73
+ * reads, which is the same as reporting nothing.
74
+ */
75
+ function topicKey(heading: string): string {
76
+ const cut = heading.search(/[—–:(-]/u);
77
+ return (cut < 0 ? heading : heading.slice(0, cut)).trim().toLowerCase();
78
+ }
79
+
80
+ /**
81
+ * Shipped sections the live brief has no heading for.
82
+ *
83
+ * Matched on {@link topicKey}, so a retitled section counts as present. The
84
+ * remaining bias is deliberate: this decides what to *offer* for a hand-merge, and
85
+ * a section reported that the operator already covers costs them one read, while a
86
+ * new protocol silently counted as present costs them the protocol.
87
+ */
88
+ export function missingSections(live: string, rendered: string): string[] {
89
+ const present = new Set(headings(live).map(topicKey));
90
+ return headings(rendered).filter((h) => !present.has(topicKey(h)));
91
+ }
92
+
93
+ /** Section bodies from a rendered template, keyed by heading, for reporting. */
94
+ export function sectionText(rendered: string, heading: string): string {
95
+ const lines = rendered.split("\n");
96
+ const start = lines.findIndex((l) => l.startsWith("## ") && l.slice(3).trim() === heading);
97
+ if (start < 0) return "";
98
+ let end = lines.length;
99
+ for (let i = start + 1; i < lines.length; i++) {
100
+ const line = lines[i];
101
+ if (line !== undefined && line.startsWith("## ")) {
102
+ end = i;
103
+ break;
104
+ }
105
+ }
106
+ return lines.slice(start, end).join("\n").trimEnd();
107
+ }
108
+
109
+ /** An unfilled `{{KEY}}` coordinate in a template nobody rendered. */
110
+ const PLACEHOLDER_PATTERN = /\{\{[A-Za-z0-9_]+\}\}/;
111
+
112
+ /** What a check found, and what a caller may do about it. */
113
+ export type BriefStatus =
114
+ | { kind: "current" }
115
+ /** Banner present and the shipped half differs: a merge is exact. */
116
+ | { kind: "mergeable"; merged: string; liveShipped: string; freshShipped: string }
117
+ /** No banner, so the boundary is unknown and only reporting is honest. */
118
+ | { kind: "unsplittable"; missing: string[] }
119
+ /** Template never rendered, so merging it would write `{{PROJECT}}` into a brief. */
120
+ | { kind: "unrendered"; missing: string[] };
121
+
122
+ /**
123
+ * Compares a live brief against the freshly rendered template.
124
+ *
125
+ * `rendered` should come from `renderBriefForProject`, so the coordinates already
126
+ * match and a diff reflects policy changes rather than substitution noise. A raw
127
+ * template is accepted — a host that runs only the supervising session has no
128
+ * config to render from — but it can only ever produce a report.
129
+ */
130
+ export function checkBrief(live: string, rendered: string): BriefStatus {
131
+ const liveHalves = splitBrief(live);
132
+ const freshHalves = splitBrief(rendered);
133
+
134
+ // A template without the banner is a packaging error, not an operator problem,
135
+ // so treat it the same as an unmergeable live brief rather than inventing a cut.
136
+ if (liveHalves === undefined || freshHalves === undefined) {
137
+ return { kind: "unsplittable", missing: missingSections(live, rendered) };
138
+ }
139
+
140
+ // Enforced here rather than at each caller: merging an unrendered template would
141
+ // write `{{PROJECT}}` into a live standing prompt, and a session reading its own
142
+ // coordinates as a literal placeholder is worse than an out-of-date brief.
143
+ if (PLACEHOLDER_PATTERN.test(freshHalves.shipped)) {
144
+ return { kind: "unrendered", missing: missingSections(live, rendered) };
145
+ }
146
+
147
+ if (liveHalves.shipped === freshHalves.shipped) return { kind: "current" };
148
+
149
+ return {
150
+ kind: "mergeable",
151
+ // The operator's half is carried across untouched. This is the whole safety
152
+ // property: an upgrade that reformats one of their sections is an upgrade
153
+ // nobody runs twice.
154
+ merged: freshHalves.shipped + liveHalves.owned,
155
+ liveShipped: liveHalves.shipped,
156
+ freshShipped: freshHalves.shipped,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Line-level diff of the two shipped halves, for a human to read before saying
162
+ * yes. Deliberately not a real diff algorithm: the shipped half changes by whole
163
+ * sections between versions, so listing removed and added lines in order is both
164
+ * enough to review and impossible to misread as a merge preview.
165
+ */
166
+ export function shippedDiff(before: string, after: string): string {
167
+ const old = new Set(before.split("\n"));
168
+ const now = new Set(after.split("\n"));
169
+ const lines: string[] = [];
170
+ for (const line of before.split("\n")) {
171
+ if (!now.has(line) && line.trim() !== "") lines.push(`- ${line}`);
172
+ }
173
+ for (const line of after.split("\n")) {
174
+ if (!old.has(line) && line.trim() !== "") lines.push(`+ ${line}`);
175
+ }
176
+ return lines.join("\n");
177
+ }
178
+
179
+ /**
180
+ * Writes the merged brief, leaving the previous one beside it.
181
+ *
182
+ * The backup is not optional and not configurable: this file is a standing
183
+ * prompt an operator may have spent an hour on, and the one thing an upgrade
184
+ * must never do is be the reason it is gone.
185
+ */
186
+ export function writeMergedBrief(path: string, merged: string): string {
187
+ const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
188
+ writeFileSync(backup, readFileSync(path));
189
+ writeFileSync(path, merged);
190
+ return backup;
191
+ }
192
+
193
+ /** The check rendered for a terminal, including what to do next. */
194
+ export function formatBriefStatus(path: string, status: BriefStatus): string {
195
+ if (status.kind === "current") {
196
+ return [`brief ${path}`, "", "up to date — its shipped half matches this version of the template."].join("\n");
197
+ }
198
+
199
+ if (status.kind === "mergeable") {
200
+ return [
201
+ `brief ${path}`,
202
+ "",
203
+ "This version of the package ships a different brief above the YOURS TO EDIT",
204
+ "banner. Everything below the banner is yours and would be carried across",
205
+ "unchanged.",
206
+ "",
207
+ shippedDiff(status.liveShipped, status.freshShipped),
208
+ "",
209
+ "Apply it with: omp-conductor brief-upgrade --apply",
210
+ "The previous file is kept beside it as ORCHESTRATOR.md.bak-<timestamp>.",
211
+ ].join("\n");
212
+ }
213
+
214
+ const lines = [`brief ${path}`, ""];
215
+ lines.push(
216
+ ...(status.kind === "unrendered"
217
+ ? [
218
+ "The shipped template still carries its {{PLACEHOLDER}} coordinates, because no",
219
+ "project config resolved on this host. Merging it would write those literals",
220
+ "into a live standing prompt, so this can only be reported on. Run it with",
221
+ "--project on a host that has the config to apply an upgrade.",
222
+ ]
223
+ : [
224
+ "This brief has no YOURS TO EDIT banner, so it was written by hand or predates",
225
+ "the template split. There is no way to tell which lines are yours, so nothing",
226
+ "will be rewritten automatically.",
227
+ ]),
228
+ );
229
+ if (status.missing.length === 0) {
230
+ lines.push("", "It already has a heading for every section the template ships.");
231
+ return lines.join("\n");
232
+ }
233
+ lines.push(
234
+ "",
235
+ `Sections the shipped template has and this brief does not (${status.missing.length}):`,
236
+ ...status.missing.map((h) => ` - ${h}`),
237
+ "",
238
+ "Merge the ones you want by hand, or ask the session running from this brief to",
239
+ "propose them through its own amendment protocol.",
240
+ );
241
+ return lines.join("\n");
242
+ }
@@ -54,6 +54,27 @@ For each one, pick exactly one of three outcomes:
54
54
  - **It is already done.** The PR is green and waiting on a human merge. Note it,
55
55
  with the link, and move on. You do not merge it.
56
56
 
57
+ **Then check for orphans.** A worker is a process, and processes die: a daemon
58
+ restart, a host reboot, a kill. The `agent:in-progress` label survives that death
59
+ by design — it is the guard that stops the next tick double-dispatching — but
60
+ nothing removes it, so a dead worker's issue sits "in progress" forever, occupying
61
+ a slot that no longer exists. Compare the in-progress labels against the active
62
+ runs `omp-conductor status` just showed you: **an in-progress issue with no
63
+ matching active run is an orphan.**
64
+
65
+ For an orphan, look at what the dead worker left — its branch, any commits, an
66
+ open PR — then pick one:
67
+
68
+ - **Real progress exists** (commits or an open PR). Note the issue, the branch and
69
+ what state it reached, and remove the in-progress label so the loop can re-claim
70
+ it. The next worker starts from the branch's actual state rather than from
71
+ nothing, and the attempt counter still protects against a loop of deaths.
72
+ - **Nothing useful exists.** Remove the in-progress label and let the next tick
73
+ re-claim it clean.
74
+
75
+ Never leave an orphan holding a slot "to be safe": a label nobody is working under
76
+ is not safety, it is a deadlocked fleet that looks busy.
77
+
57
78
  ## Duty 2 — groom
58
79
 
59
80
  Keep the queue worth draining.
package/src/cli.ts CHANGED
@@ -5,8 +5,13 @@
5
5
  * process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
6
6
  * cannot drift apart.
7
7
  */
8
+ import { readFileSync } from "node:fs";
9
+ import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
10
+ import { findProject, loadConfig } from "./config.ts";
8
11
  import { formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
9
12
  import { healthCheck, livingDaemon, startDaemon, stopDaemon } from "./lifecycle.ts";
13
+ import { briefPathForProject, renderBriefForProject, shippedBriefTemplate } from "./setup.ts";
14
+ import type { ProjectConfig } from "./types.ts";
10
15
 
11
16
  const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
12
17
 
@@ -18,6 +23,7 @@ usage:
18
23
  omp-conductor daemon [--once] [--port N] [--project NAME]
19
24
  omp-conductor pause
20
25
  omp-conductor resume
26
+ omp-conductor brief-upgrade [--apply] [--file PATH] [--project NAME]
21
27
  omp-conductor help
22
28
 
23
29
  start run the dispatch loop in the background and wait until it answers
@@ -32,6 +38,14 @@ usage:
32
38
  and exits. This is what \`start\` launches.
33
39
  pause stop claiming new work. The running daemon notices on its next tick.
34
40
  resume allow claiming again.
41
+ brief-upgrade
42
+ compare a project's ORCHESTRATOR.md against the brief this version of
43
+ the package ships. Reports by default; --apply replaces the half above
44
+ the YOURS TO EDIT banner and keeps everything below it, backing the old
45
+ file up first. --file checks a brief that is not where the wizard would
46
+ have put it, on a host that may have no config at all. Nothing is
47
+ written for a brief with no banner, or when no config resolved and the
48
+ template still carries its {{PLACEHOLDER}} coordinates.
35
49
  help print this text (also --help, -h).
36
50
 
37
51
  Pause is a flag file under the state directory, so it applies to every project
@@ -159,6 +173,60 @@ try {
159
173
  process.stdout.write("resumed — work will be claimed on the next tick\n");
160
174
  break;
161
175
 
176
+ case "brief-upgrade": {
177
+ // `--file` exists because a real fleet's brief is often not where the wizard
178
+ // would have put it: the supervising session runs from its own directory, and
179
+ // that host may never have configured a dispatch daemon at all. Without this
180
+ // the command cannot check the one file it was written for.
181
+ const override = flag(argv, "file");
182
+ let project: ProjectConfig | undefined;
183
+ let path: string;
184
+ if (override === undefined) {
185
+ project = findProject(loadConfig(), flag(argv, "project"));
186
+ path = briefPathForProject(project);
187
+ } else {
188
+ path = override;
189
+ try {
190
+ project = findProject(loadConfig(), flag(argv, "project"));
191
+ } catch {
192
+ // No config here, or several projects and no name given. With an explicit
193
+ // file we need neither, and refusing would make the command unusable on a
194
+ // fleet host that runs only the supervising session.
195
+ project = undefined;
196
+ }
197
+ }
198
+
199
+ let live: string;
200
+ try {
201
+ live = readFileSync(path, "utf8");
202
+ } catch {
203
+ process.stderr.write(
204
+ `omp-conductor: no brief at ${path}` +
205
+ `${override === undefined ? " — run /conductor setup and say yes to writing ORCHESTRATOR.md." : "."}\n`,
206
+ );
207
+ process.exit(1);
208
+ }
209
+
210
+ // With no config there are no coordinates to substitute, so the template is
211
+ // compared raw. Headings carry no placeholders, so the section-level report is
212
+ // unaffected; the note below keeps the printed text honest.
213
+ const status = checkBrief(live, project === undefined ? shippedBriefTemplate() : renderBriefForProject(project));
214
+ // Report first, always: --apply on a brief with no banner must not be the
215
+ // command that silently discards an operator's hand-written policy.
216
+ process.stdout.write(`${formatBriefStatus(path, status)}\n`);
217
+ if (project === undefined) {
218
+ process.stdout.write(
219
+ "\nnote: no conductor config resolved on this host, so the template's\n" +
220
+ "{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
221
+ );
222
+ }
223
+ if (argv.includes("--apply") && status.kind === "mergeable") {
224
+ const backup = writeMergedBrief(path, status.merged);
225
+ process.stdout.write(`\napplied — previous brief kept at ${backup}\n`);
226
+ }
227
+ break;
228
+ }
229
+
162
230
  case "help":
163
231
  case "--help":
164
232
  case "-h":
package/src/plugin.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * tested; this file turns answers into questions and back again. The invariant
11
11
  * worth protecting is on `setup()` below — nothing is written before the confirm.
12
12
  */
13
- import { existsSync } from "node:fs";
14
- import { configPath, loadConfig, saveConfig } from "./config.ts";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
15
+ import { configPath, findProject, loadConfig, saveConfig } from "./config.ts";
15
16
  import {
16
17
  armConductor,
17
18
  formatStatus,
@@ -25,12 +26,14 @@ import {
25
26
  ORCHESTRATOR_BRIEF_NAME,
26
27
  REPORT_SCOPE_CHOICES,
27
28
  SETUP_DEFAULTS,
29
+ briefPathForProject,
28
30
  buildConfig,
29
31
  checkTokenScopes,
30
32
  createMissingLabels,
31
33
  detectTelegram,
32
34
  orchestratorBriefPath,
33
35
  planLabels,
36
+ renderBriefForProject,
34
37
  summarisePlan,
35
38
  writeOrchestratorBrief,
36
39
  type SetupAnswers,
@@ -100,13 +103,19 @@ const SUBCOMMANDS: Completion[] = [
100
103
  { value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
101
104
  { value: "pause", label: "pause", description: "stop claiming new work" },
102
105
  { value: "resume", label: "resume", description: "allow claiming again" },
106
+ {
107
+ value: "brief-upgrade",
108
+ label: "brief-upgrade",
109
+ description: "check ORCHESTRATOR.md against the brief this version ships",
110
+ },
103
111
  ];
104
112
 
105
113
  const USAGE = [
106
- "/conductor setup [project] create or update a project, then arm after you confirm",
107
- "/conductor status [project] pause state, caps, active runs, today's usage",
108
- "/conductor pause stop claiming new work",
109
- "/conductor resume allow claiming again",
114
+ "/conductor setup [project] create or update a project, then arm after you confirm",
115
+ "/conductor status [project] pause state, caps, active runs, today's usage",
116
+ "/conductor pause stop claiming new work",
117
+ "/conductor resume allow claiming again",
118
+ "/conductor brief-upgrade [project] check ORCHESTRATOR.md against the shipped brief",
110
119
  ].join("\n");
111
120
 
112
121
  /**
@@ -588,6 +597,34 @@ export default function conductorPlugin(pi: PluginApi): void {
588
597
  ctx.ui.notify("Conductor resumed — work will be claimed on the next tick.", "info");
589
598
  break;
590
599
 
600
+ case "brief-upgrade": {
601
+ const p = findProject(loadConfig(), project);
602
+ const path = briefPathForProject(p);
603
+ if (!existsSync(path)) {
604
+ ctx.ui.notify(
605
+ `No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME}.`,
606
+ "warning",
607
+ );
608
+ break;
609
+ }
610
+ const status = checkBrief(readFileSync(path, "utf8"), renderBriefForProject(p));
611
+ ctx.ui.notify(formatBriefStatus(path, status), status.kind === "current" ? "info" : "warning");
612
+ // Confirmed here rather than applied on sight: this file is a standing
613
+ // prompt the operator may have spent an hour on, so the diff they just
614
+ // read is the thing they are agreeing to.
615
+ if (status.kind === "mergeable") {
616
+ const apply = await ctx.ui.confirm(
617
+ "Upgrade the brief?",
618
+ "Replace the half above the YOURS TO EDIT banner with the one this version ships? Everything below the banner is kept exactly as it is, and the current file is backed up first.",
619
+ );
620
+ if (apply) {
621
+ const backup = writeMergedBrief(path, status.merged);
622
+ ctx.ui.notify(`Brief upgraded. Previous version kept at ${backup}.`, "info");
623
+ }
624
+ }
625
+ break;
626
+ }
627
+
591
628
  default:
592
629
  ctx.ui.notify(
593
630
  `${sub ? `Unknown subcommand "${sub}".` : "Pick a subcommand."}\n\n${USAGE}` +
package/src/setup.ts CHANGED
@@ -27,6 +27,7 @@ import { configPath, resolveCaps, stateDir } from "./config.ts";
27
27
  import {
28
28
  CONFIG_VERSION,
29
29
  DEFAULT_CAPS,
30
+ DEFAULT_REPORT_SCOPE,
30
31
  type Caps,
31
32
  type ConductorConfig,
32
33
  type ProjectConfig,
@@ -392,31 +393,55 @@ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): Conduc
392
393
  }
393
394
 
394
395
  /**
395
- * Where the operator's own brief lands: beside the worktrees, under the state
396
+ * Where a configured project's brief lives: beside its worktrees, under the state
396
397
  * directory, so it is on the same disk the fleet already owns and survives a
397
- * reinstall of the package. Derived from the answers rather than fixed, so a
398
+ * reinstall of the package. Derived from the project rather than fixed, so a
398
399
  * project that ever gains a chosen workspace root keeps its brief with it.
399
400
  */
400
- export function orchestratorBriefPath(a: SetupAnswers): string {
401
- return join(buildProject(a).workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
401
+ export function briefPathForProject(p: ProjectConfig): string {
402
+ return join(p.workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
402
403
  }
403
404
 
404
405
  /**
405
- * The shipped template with this project's real values in it.
406
+ * The brief template exactly as shipped, placeholders and all.
407
+ *
408
+ * Exported for the upgrade check, which has to be able to read the shipped text
409
+ * on a host that has no config to render it against.
410
+ */
411
+ export function shippedBriefTemplate(): string {
412
+ return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8");
413
+ }
414
+
415
+ /**
416
+ * The shipped template with a configured project's real values in it.
406
417
  *
407
418
  * Only the coordinates and the chosen scope are substituted: the policy text is
408
419
  * left exactly as shipped, because from here on the file is the operator's to
409
420
  * edit and nothing in this package reads it back.
421
+ *
422
+ * Takes a `ProjectConfig` rather than answers so that a *later* upgrade check can
423
+ * reproduce the same render from what is on disk, months after the wizard's
424
+ * answers are gone.
410
425
  */
411
- export function renderOrchestratorBrief(a: SetupAnswers): string {
426
+ export function renderBriefForProject(p: ProjectConfig): string {
412
427
  return renderBrief(readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8"), {
413
- PROJECT: a.projectName,
414
- TRACKER_REPO: a.trackerRepo,
415
- QUEUE_LABEL: a.queueLabel,
416
- REPORT_SCOPE: a.reportScope,
428
+ PROJECT: p.name,
429
+ TRACKER_REPO: p.tracker.repo,
430
+ QUEUE_LABEL: p.queueLabel,
431
+ REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
417
432
  });
418
433
  }
419
434
 
435
+ /** Wizard-time path, via the project the answers describe. */
436
+ export function orchestratorBriefPath(a: SetupAnswers): string {
437
+ return briefPathForProject(buildProject(a));
438
+ }
439
+
440
+ /** Wizard-time render, via the project the answers describe. */
441
+ export function renderOrchestratorBrief(a: SetupAnswers): string {
442
+ return renderBriefForProject(buildProject(a));
443
+ }
444
+
420
445
  /**
421
446
  * Writes the rendered brief and returns where it went.
422
447
  *