omp-conductor 0.3.9 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,30 @@
1
1
  # Orchestrator brief — {{PROJECT}}
2
2
 
3
- `/conductor setup` renders this template with your project's real values and
4
- writes it into your workspace. From that moment it is **yours**: the conductor
5
- never reads it back, never rewrites it, and never enforces a word of it. It is
6
- the standing prompt for the one long-lived omp session that supervises the fleet.
7
- The *session* is the exception to "never rewrites it" — you may amend this file
8
- yourself, with your operator's approval, per **Learning loop** below.
9
-
10
- Point the heartbeat at it — a `.conductor-tick.json` in that session's working
11
- directory, whose `message` tells the session to run its loop from this file.
12
-
13
- This is the **floor**, not the finished article: it ships conservative so an
14
- unedited brief is still a safe fleet. To have it tailored to your project —
15
- interviewed release boundary, your own hard boundaries, the reporting scope
16
- written out as the one you chose — ask an omp session to read
3
+ This file is the **package floor**: duties, tiers, hard boundaries, and the
4
+ Learning loop. It ships inside `omp-conductor` and is re-rendered into your
5
+ workspace on every tick (composed with `POLICY.md` as `ORCHESTRATOR.md` for the
6
+ session). Protocol updates arrive with `npm install` you do not brief-upgrade
7
+ the floor.
8
+
9
+ Fleet-specific policy — Releases, Project context, Reporting, Amendments — lives
10
+ in **`POLICY.md`** beside this composed brief. The Learning loop edits
11
+ `POLICY.md` only. Never edit the package template; never treat the composed
12
+ `ORCHESTRATOR.md` as the place to hand-amend policy (it is regenerated).
13
+
14
+ Point the heartbeat at the workspace that holds `ORCHESTRATOR.md` /
15
+ `POLICY.md` a `.conductor-tick.json` whose default message re-reads both.
16
+
17
+ This floor ships conservative so an unedited `POLICY.md` is still a safe fleet.
18
+ To tailor Releases and Project context, ask an omp session to read
17
19
  `skill://conductor-onboarding` and onboard you.
18
20
 
19
21
  ---
20
22
 
21
- You are the orchestrator for **{{PROJECT}}**. You do not write product code and
22
- you do not touch a worker's branch. You keep the queue moving, and you are the
23
- first responder when a worker gets stuck.
23
+ You are the orchestrator for **{{PROJECT}}**. You do not write product code
24
+ **Hard boundaries** below names the exact acts that are out (checkout / commit /
25
+ push inside a worker's worktree; inventing a commit identity) and the one that
26
+ is in (`gh pr update-branch` for a green PR that fell behind). You keep the
27
+ queue moving, and you are the first responder when a worker gets stuck.
24
28
 
25
29
  You are prompted on a timer. Each tick: do the three duties below, then stop.
26
30
 
@@ -192,27 +196,27 @@ Not yours to relax:
192
196
  branch is how you satisfy it, not how you dodge it.
193
197
 
194
198
  **Your own** merge and release authority is not decided here. It is whatever your
195
- operator granted at setup time, stated in the first paragraph of **Releases**
196
- below; ungranted, it is none — you do not merge, tag, publish or deploy either.
197
- That grant is a deliberate operator decision, changed by re-running setup rather
198
- than by editing this file. The five boundaries above are not.
199
+ operator granted at setup time, stated in the first paragraph of **Releases** in
200
+ `POLICY.md`; ungranted, it is none — you do not merge, tag, publish or deploy
201
+ either. That grant is a deliberate operator decision, changed by re-running setup
202
+ rather than by editing policy prose. The five boundaries above are not.
199
203
 
200
204
  ## Learning loop
201
205
 
202
- This file is yours to amend, and amending it is part of the job. Two things
206
+ `POLICY.md` is yours to amend, and amending it is part of the job. Two things
203
207
  trigger an amendment:
204
208
 
205
209
  - **Your operator corrects you.** They told you to do something differently. That
206
- correction belongs in this file, or you will need it again next week.
207
- - **This brief contradicts repo reality.** A duty names a step that no longer
208
- exists, or tells you to do something a repo's own `AGENTS.md` forbids. The repo
209
- wins.
210
+ correction belongs in `POLICY.md`, or you will need it again next week.
211
+ - **Policy contradicts repo reality.** A duty or Releases step names machinery that
212
+ no longer exists, or tells you to do something a repo's own `AGENTS.md` forbids.
213
+ The repo wins.
210
214
 
211
215
  The protocol, in order:
212
216
 
213
- 1. **Draft the exact replacement.** Quote the lines as they stand, then the lines
214
- you propose. A diff, not a description of one. This full text is what you
215
- *apply* on a yes — it is not what you send.
217
+ 1. **Draft the exact replacement** against `POLICY.md`. Quote the lines as they
218
+ stand, then the lines you propose. A diff, not a description of one. This full
219
+ text is what you *apply* on a yes — it is not what you send.
216
220
  2. **Ask, once — a single yes/no question, written for a phone.** It goes over
217
221
  the escalation channel (the `ask` tool — it reaches your operator's Telegram),
218
222
  and Telegram renders none of your markdown: asterisks and backticks arrive as
@@ -223,109 +227,28 @@ The protocol, in order:
223
227
  two-line change.
224
228
  - Keep the whole proposal readable on one phone screen. If the edit is too
225
229
  big for that, send the one-sentence version of each change and say the
226
- full text lands in the file on yes — the diff stays in your transcript for
227
- anyone who wants it verbatim.
228
- 3. **On yes, apply it** by editing this file yourself. On no, or on no answer at
229
- all, drop it and do not re-ask that amendment.
230
- 4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
231
- date, what triggered it, a one-sentence summary.
230
+ full text lands in `POLICY.md` on yes — the diff stays in your transcript
231
+ for anyone who wants it verbatim.
232
+ 3. **On yes, apply it** by editing **`POLICY.md`** yourself never the package
233
+ floor, and never by relying on edits to the composed `ORCHESTRATOR.md` (that
234
+ file is regenerated from the floor + `POLICY.md`). **On explicit no, drop
235
+ it** forever and do not re-ask that amendment. **On cancel, timeout, or no
236
+ answer**, park it — that means "not now", not "never": mention it once in the
237
+ next report as `pending amendment: <one-liner> — say 'apply it' or 'drop it'`,
238
+ never re-open the yes/no dialog, and drop it if still unanswered after 7 days.
239
+ A cancelled dialog is not a permanent rejection.
240
+ 4. **Log it.** Append one line to **Amendments** at the bottom of `POLICY.md`:
241
+ the date, what triggered it, a one-sentence summary.
232
242
  5. **Offer general fixes upstream.** Ask one question of the amendment you just
233
243
  applied: does it fix *this fleet* (a repo name, a path, a cap, your infra), or
234
- does it fix *how the brief works* (a duty's logic, a protocol, a failure mode
235
- any fleet would hit)? The second kind belongs in the shipped template, or
244
+ does it fix *how the floor works* (a duty's logic, a protocol, a failure mode
245
+ any fleet would hit)? The second kind belongs in the shipped package floor, or
236
246
  every other operator re-learns it the hard way. Say so in your report, and
237
247
  offer to file it: an issue on `TerrifiedBug/conductor` quoting the approved
238
248
  diff and the incident that triggered it. File it only when your operator says
239
249
  yes — it is their name on the account.
240
250
 
241
251
  Two limits. You never propose relaxing **Hard boundaries** — that section changes
242
- only when your operator hand-edits it. And at most one proposal per tick: an
243
- amendment waits for the three duties to finish, it never interrupts them.
244
-
245
- <!-- ==================================================================== -->
246
- <!-- YOURS TO EDIT — everything below is your policy, not the package's. -->
247
- <!-- The conductor never reads this file back, so edit freely. -->
248
- <!-- ==================================================================== -->
249
-
250
- ## Releases (yours to define)
251
-
252
- {{RELEASES_DEFAULT}}
253
-
254
- Releases are yours or nobody's. A worker can never take them, so this section is
255
- the only place they can be delegated, and it is the only place your merge
256
- authority is decided.
257
-
258
- Replace the paragraph above only if your operator is deliberately delegating. If
259
- they are, be specific: an orchestrator with a vague release mandate is one that
260
- eventually publishes something at 03:00. Spell out all seven.
261
-
262
- - **Whether you may merge**, and which PRs. Release work usually needs it, and a
263
- procedure that has you landing a PR without saying so leaves you inferring
264
- permission. Note that **one at a time, re-checked against the base branch** binds
265
- you here exactly as it binds a human; that part is a hard boundary. When that
266
- re-check finds a green PR that is merely *behind*, the answer is
267
- `gh pr update-branch` and a wait for the fresh run — never closing it, and
268
- never an admin bypass. Merging promptly is itself the remedy that stops the
269
- next PR falling behind: a queue of green PRs left unmerged makes each one
270
- stale in turn.
271
- - **The release authority**, named. Which workflow or command ships this repo, and
272
- how it is invoked. If it is a protected or dispatchable workflow, your
273
- instruction is to *dispatch it and verify the run*. You never reproduce what it
274
- does by hand, even when you can see every step it takes: a hand-rolled release
275
- skips the checks the workflow exists to enforce.
276
- - **What** may be released: which packages or images, from which branch.
277
- - **When**: the batching unit (a sprint, an epic's children all closed, N merged
278
- issues waiting, N days elapsed), and which named checks must be green first.
279
- Never one release per merged issue.
280
- - **What proof** you must hold before calling it shipped: named check results, run
281
- conclusions, published versions or digests you actually read. Not an impression.
282
- - **Where your leg ends**, in one sentence with a concrete artefact in it (a merge
283
- commit, a published version). If you cannot say it in one sentence, it is not a
284
- boundary.
285
- - **What stays permanently forbidden**, with the source. Cite the file that says so
286
- (`repos/<repo>/AGENTS.md`, a runbook) so the rule survives a future session that
287
- thinks it has found a shortcut. Force-push, secrets and production data are
288
- forbidden everywhere, always.
289
-
290
- Releases are the section most likely to go stale, because a workflow can be
291
- replaced while this text still reads plausible. If you find this section
292
- describing machinery the repo no longer has, that is a **Learning loop** trigger:
293
- propose the corrected steps.
294
-
295
- ## Project context (filled during onboarding)
296
-
297
- Empty until an onboarding session fills it in: the product in a paragraph, a map
298
- of which repo owns what, and the grooming guidance Duty 2 needs to judge priority
299
- and spot issues that would collide. Ask an omp session to read
300
- `skill://conductor-onboarding` to have it written.
301
-
302
- ## Reporting
303
-
304
- Your report scope is **`{{REPORT_SCOPE}}`**. Both scopes, spelled out:
305
-
306
- - **`escalations`** — you speak when a human is needed, and once a day otherwise.
307
- That is: every tier-2 escalation immediately, carrying the issue link and the
308
- single question; plus one daily digest naming what merged, what is green and
309
- waiting on a merge, and what is stuck and why. Every other tick is silent.
310
- - **`material`** — everything in `escalations`, plus each material event as it
311
- happens: a run reaching a green PR (with the link), a run that failed twice, an
312
- issue you pulled off the queue, a cap that stopped the fleet. A tick where
313
- nothing changed still says nothing — "no change" is not an event.
314
-
315
- **Delivery.** Your end-of-turn text reaches your operator only on a turn that
316
- *began* as an inbound Telegram message. A tick did not: it is injected locally,
317
- so a report you merely write at the end of one is read by nobody, however well
318
- you wrote it. On a tick, deliver every reportable event by explicitly calling
319
- `telegram_send`, as plain text — Telegram renders none of your markdown, so
320
- asterisks and backticks arrive as literal characters and a pasted section becomes
321
- a wall. Never claim something was reported unless you made that call and saw it
322
- succeed. And a `cancelled` or errored `telegram_ask` is a delivery failure, not
323
- an answer: re-deliver it with `telegram_send`, or report the channel as broken.
324
- It is never "asked once, no reply, dropped".
325
-
326
- Neither scope licenses narration. No progress updates, no "checking the queue
327
- now", no restating this brief back. Evidence, or silence.
328
-
329
- ## Amendments
330
-
331
- <!-- one line per approved amendment: date — trigger — summary -->
252
+ only in the shipped package floor, never via this loop. And at most one proposal
253
+ per tick: an amendment waits for the three duties to finish, it never interrupts
254
+ them.
@@ -0,0 +1,89 @@
1
+ # Fleet policy — {{PROJECT}}
2
+
3
+ This file is **yours**. The package never overwrites it after setup (except when
4
+ you explicitly re-run setup and confirm). The Learning loop in the package floor
5
+ edits this file only. The composed `ORCHESTRATOR.md` is regenerated from the
6
+ package floor + this file on every tick — do not treat that compose as durable.
7
+
8
+ ## Releases (yours to define)
9
+
10
+ {{RELEASES_DEFAULT}}
11
+
12
+ Releases are yours or nobody's. A worker can never take them, so this section is
13
+ the only place they can be delegated, and it is the only place your merge
14
+ authority is decided.
15
+
16
+ Replace the paragraph above only if your operator is deliberately delegating. If
17
+ they are, be specific: an orchestrator with a vague release mandate is one that
18
+ eventually publishes something at 03:00. Spell out all seven.
19
+
20
+ - **Whether you may merge**, and which PRs. Release work usually needs it, and a
21
+ procedure that has you landing a PR without saying so leaves you inferring
22
+ permission. Note that **one at a time, re-checked against the base branch** binds
23
+ you here exactly as it binds a human; that part is a hard boundary. When that
24
+ re-check finds a green PR that is merely *behind*, the answer is
25
+ `gh pr update-branch` and a wait for the fresh run — never closing it, and
26
+ never an admin bypass. Merging promptly is itself the remedy that stops the
27
+ next PR falling behind: a queue of green PRs left unmerged makes each one
28
+ stale in turn.
29
+ - **The release authority**, named. Which workflow or command ships this repo, and
30
+ how it is invoked. If it is a protected or dispatchable workflow, your
31
+ instruction is to *dispatch it and verify the run*. You never reproduce what it
32
+ does by hand, even when you can see every step it takes: a hand-rolled release
33
+ skips the checks the workflow exists to enforce.
34
+ - **What** may be released: which packages or images, from which branch.
35
+ - **When**: the batching unit (a sprint, an epic's children all closed, N merged
36
+ issues waiting, N days elapsed), and which named checks must be green first.
37
+ Never one release per merged issue.
38
+ - **What proof** you must hold before calling it shipped: named check results, run
39
+ conclusions, published versions or digests you actually read. Not an impression.
40
+ - **Where your leg ends**, in one sentence with a concrete artefact in it (a merge
41
+ commit, a published version). If you cannot say it in one sentence, it is not a
42
+ boundary.
43
+ - **What stays permanently forbidden**, with the source. Cite the file that says so
44
+ (`repos/<repo>/AGENTS.md`, a runbook) so the rule survives a future session that
45
+ thinks it has found a shortcut. Force-push, secrets and production data are
46
+ forbidden everywhere, always.
47
+
48
+ Releases are the section most likely to go stale, because a workflow can be
49
+ replaced while this text still reads plausible. If you find this section
50
+ describing machinery the repo no longer has, that is a **Learning loop** trigger:
51
+ propose the corrected steps.
52
+
53
+ ## Project context (filled during onboarding)
54
+
55
+ Empty until an onboarding session fills it in: the product in a paragraph, a map
56
+ of which repo owns what, and the grooming guidance Duty 2 needs to judge priority
57
+ and spot issues that would collide. Ask an omp session to read
58
+ `skill://conductor-onboarding` to have it written.
59
+
60
+ ## Reporting
61
+
62
+ Your report scope is **`{{REPORT_SCOPE}}`**. Both scopes, spelled out:
63
+
64
+ - **`escalations`** — you speak when a human is needed, and once a day otherwise.
65
+ That is: every tier-2 escalation immediately, carrying the issue link and the
66
+ single question; plus one daily digest naming what merged, what is green and
67
+ waiting on a merge, and what is stuck and why. Every other tick is silent.
68
+ - **`material`** — everything in `escalations`, plus each material event as it
69
+ happens: a run reaching a green PR (with the link), a run that failed twice, an
70
+ issue you pulled off the queue, a cap that stopped the fleet. A tick where
71
+ nothing changed still says nothing — "no change" is not an event.
72
+
73
+ **Delivery.** Your end-of-turn text reaches your operator only on a turn that
74
+ *began* as an inbound Telegram message. A tick did not: it is injected locally,
75
+ so a report you merely write at the end of one is read by nobody, however well
76
+ you wrote it. On a tick, deliver every reportable event by explicitly calling
77
+ `telegram_send`, as plain text — Telegram renders none of your markdown, so
78
+ asterisks and backticks arrive as literal characters and a pasted section becomes
79
+ a wall. Never claim something was reported unless you made that call and saw it
80
+ succeed. And a `cancelled` or errored `telegram_ask` is a delivery failure, not
81
+ an answer: re-deliver it with `telegram_send`, or report the channel as broken.
82
+ It is never "asked once, no reply, dropped".
83
+
84
+ Neither scope licenses narration. No progress updates, no "checking the queue
85
+ now", no restating this brief back. Evidence, or silence.
86
+
87
+ ## Amendments
88
+
89
+ <!-- one line per approved amendment: date — trigger — summary -->
package/src/cli.ts CHANGED
@@ -6,8 +6,19 @@
6
6
  * cannot drift apart.
7
7
  */
8
8
  import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
9
+ import { dirname, join } from "node:path";
10
+ import {
11
+ applyRetrofit,
12
+ checkBrief,
13
+ formatBriefStatus,
14
+ formatMigrateResult,
15
+ formatRetrofitProposal,
16
+ formatRetrofitRefusal,
17
+ inspectBriefLayout,
18
+ migrateToPolicy,
19
+ proposeRetrofit,
20
+ writeMergedBrief,
21
+ } from "./brief-upgrade.ts";
11
22
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
12
23
  import { dbPath, formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
13
24
  import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
@@ -22,7 +33,13 @@ import {
22
33
  writeRecord,
23
34
  } from "./lifecycle.ts";
24
35
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
25
- import { briefPathForProject, renderBriefForProject, shippedBriefTemplate } from "./setup.ts";
36
+ import {
37
+ briefPathForProject,
38
+ policyPathForProject,
39
+ renderBriefForProject,
40
+ renderFloorForProject,
41
+ shippedBriefTemplate,
42
+ } from "./setup.ts";
26
43
  import { LIVE_STATES, openStore } from "./store.ts";
27
44
  import { makeTracker } from "./tracker/github.ts";
28
45
  import type { ProjectConfig } from "./types.ts";
@@ -41,7 +58,7 @@ usage:
41
58
  omp-conductor pause
42
59
  omp-conductor resume
43
60
  omp-conductor graph-setup [--project NAME] [--write]
44
- omp-conductor brief-upgrade [--apply] [--file PATH] [--project NAME]
61
+ omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
45
62
  omp-conductor help
46
63
 
47
64
  start run the dispatch loop in the background and wait until it answers
@@ -79,13 +96,13 @@ usage:
79
96
  prints the systemctl line to run — it never runs systemctl itself.
80
97
  Exits 1 when no repo in the project has graphProject configured.
81
98
  brief-upgrade
82
- compare a project's ORCHESTRATOR.md against the brief this version of
83
- the package ships. Reports by default; --apply replaces the half above
84
- the YOURS TO EDIT banner and keeps everything below it, backing the old
85
- file up first. --file checks a brief that is not where the wizard would
86
- have put it, on a host that may have no config at all. Nothing is
87
- written for a brief with no banner, or when no config resolved and the
88
- template still carries its {{PLACEHOLDER}} coordinates.
99
+ inspect the brief overlay (package floor + POLICY.md). Reports by
100
+ default. --migrate lifts a bannered ORCHESTRATOR.md owned half into
101
+ POLICY.md and recomposes. --retrofit proposes inserting the YOURS TO
102
+ EDIT banner before the first Releases/Project context/Reporting/
103
+ Amendments heading (#20); --retrofit --apply writes it. Legacy
104
+ --apply still merges a bannered single-file brief. --file checks a
105
+ brief that is not where the wizard would have put it.
89
106
  help print this text (also --help, -h).
90
107
 
91
108
  Pause is a flag file under the state directory, so it applies to every project
@@ -526,10 +543,6 @@ try {
526
543
  }
527
544
 
528
545
  case "brief-upgrade": {
529
- // `--file` exists because a real fleet's brief is often not where the wizard
530
- // would have put it: the supervising session runs from its own directory, and
531
- // that host may never have configured a dispatch daemon at all. Without this
532
- // the command cannot check the one file it was written for.
533
546
  const override = flag(argv, "file");
534
547
  let project: ProjectConfig | undefined;
535
548
  let path: string;
@@ -541,13 +554,88 @@ try {
541
554
  try {
542
555
  project = findProject(loadConfig(), flag(argv, "project"));
543
556
  } catch {
544
- // No config here, or several projects and no name given. With an explicit
545
- // file we need neither, and refusing would make the command unusable on a
546
- // fleet host that runs only the supervising session.
547
557
  project = undefined;
548
558
  }
549
559
  }
550
560
 
561
+ const workspaceRoot = project?.workspaceRoot ?? dirname(path);
562
+ const rendered =
563
+ project === undefined ? shippedBriefTemplate() : renderBriefForProject(project);
564
+ const floor = project === undefined ? shippedBriefTemplate() : renderFloorForProject(project);
565
+ const layout = inspectBriefLayout(workspaceRoot, rendered);
566
+
567
+ if (argv.includes("--retrofit")) {
568
+ let live: string;
569
+ try {
570
+ live = readFileSync(path, "utf8");
571
+ } catch {
572
+ process.stderr.write(`omp-conductor: no brief at ${path}.\n`);
573
+ process.exit(1);
574
+ }
575
+ const result = proposeRetrofit(live);
576
+ if (result.kind === "no-cut") {
577
+ process.stdout.write(
578
+ `brief ${path}\n\nNo Releases / Project context / Reporting / Amendments heading found — cannot classify a cut.\n`,
579
+ );
580
+ process.exit(1);
581
+ }
582
+ if (result.kind === "interleaved") {
583
+ process.stdout.write(`${formatRetrofitRefusal(path, result)}\n`);
584
+ process.exit(1);
585
+ }
586
+ process.stdout.write(`${formatRetrofitProposal(path, result.proposal)}\n`);
587
+ if (argv.includes("--apply")) {
588
+ const backup = applyRetrofit(path, result.proposal);
589
+ process.stdout.write(`\napplied retrofit — previous brief kept at ${backup}\n`);
590
+ }
591
+ break;
592
+ }
593
+
594
+ if (argv.includes("--migrate")) {
595
+ if (layout.kind === "overlay") {
596
+ process.stdout.write(`${formatBriefStatus(path, layout)}\n`);
597
+ break;
598
+ }
599
+ if (layout.kind === "missing") {
600
+ process.stderr.write(`omp-conductor: no brief at ${path} to migrate.\n`);
601
+ process.exit(1);
602
+ }
603
+ if (layout.kind === "legacy-handwritten") {
604
+ process.stdout.write(
605
+ `${formatBriefStatus(path, { kind: "unsplittable", missing: layout.missing })}\n`,
606
+ );
607
+ process.exit(1);
608
+ }
609
+ if (project === undefined && /\{\{[A-Za-z0-9_]+\}\}/.test(floor)) {
610
+ process.stderr.write(
611
+ "omp-conductor: --migrate needs --project (or a config) so the floor renders without {{PLACEHOLDER}}s.\n",
612
+ );
613
+ process.exit(1);
614
+ }
615
+ if (!argv.includes("--apply")) {
616
+ process.stdout.write(
617
+ [
618
+ `migrate ${layout.orchestratorPath}`,
619
+ "",
620
+ "Would write POLICY.md from the owned half below YOURS TO EDIT,",
621
+ "then recompose ORCHESTRATOR.md from the package floor + that policy.",
622
+ "",
623
+ "Apply: omp-conductor brief-upgrade --migrate --apply",
624
+ ].join("\n") + "\n",
625
+ );
626
+ break;
627
+ }
628
+ const policyPath = project ? policyPathForProject(project) : join(workspaceRoot, "POLICY.md");
629
+ const result = migrateToPolicy({
630
+ orchestratorPath: layout.orchestratorPath,
631
+ policyPath,
632
+ floor: project ? renderFloorForProject(project) : floor,
633
+ owned: layout.owned,
634
+ });
635
+ process.stdout.write(`${formatMigrateResult(result)}\n`);
636
+ break;
637
+ }
638
+
551
639
  let live: string;
552
640
  try {
553
641
  live = readFileSync(path, "utf8");
@@ -559,12 +647,14 @@ try {
559
647
  process.exit(1);
560
648
  }
561
649
 
562
- // With no config there are no coordinates to substitute, so the template is
563
- // compared raw. Headings carry no placeholders, so the section-level report is
564
- // unaffected; the note below keeps the printed text honest.
565
- const status = checkBrief(live, project === undefined ? shippedBriefTemplate() : renderBriefForProject(project));
566
- // Report first, always: --apply on a brief with no banner must not be the
567
- // command that silently discards an operator's hand-written policy.
650
+ if (layout.kind === "overlay") {
651
+ process.stdout.write(
652
+ `${formatBriefStatus(path, { kind: "overlay", policyPath: layout.policyPath, orchestratorPath: layout.orchestratorPath })}\n`,
653
+ );
654
+ break;
655
+ }
656
+
657
+ const status = checkBrief(live, rendered);
568
658
  process.stdout.write(`${formatBriefStatus(path, status)}\n`);
569
659
  if (project === undefined) {
570
660
  process.stdout.write(
package/src/daemon.ts CHANGED
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { dirname, join, relative } from "node:path";
13
13
  import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
14
- import { createEscalator } from "./escalate.ts";
14
+ import { createEscalator, escalationIssueRef } from "./escalate.ts";
15
15
  import { graphHint } from "./graph.ts";
16
16
  import { livingDaemon } from "./lifecycle.ts";
17
17
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
@@ -369,7 +369,7 @@ async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<b
369
369
  await d.escalate(e);
370
370
  return true;
371
371
  } catch (err) {
372
- log(`escalation for #${e.issue} could not be delivered: ${errText(err)}`);
372
+ log(`escalation for ${escalationIssueRef(e.issue)} could not be delivered: ${errText(err)}`);
373
373
  return false;
374
374
  }
375
375
  }
@@ -393,13 +393,22 @@ export function salvageLines(outcome: SalvageOutcome, worktree: string): string[
393
393
  ];
394
394
  }
395
395
 
396
- return [
396
+ const where =
397
397
  `WIP committed to ${outcome.branch} @ ${outcome.sha}` +
398
- (outcome.pushed
399
- ? " and pushed — the work outlives this worktree"
400
- : ` but NOT pushed (${outcome.pushError ?? "no reason given"}) — it lives only in this host's mirror`),
401
- kept,
402
- ];
398
+ (outcome.pushed
399
+ ? " and pushed — the work outlives this worktree"
400
+ : ` but NOT pushed (${outcome.pushError ?? "no reason given"}) — it lives only in this host's mirror`);
401
+ // Manifest belongs in the escalation too: opening the commit is how the
402
+ // orchestrator talked itself into scrubbing a worker tree (#38).
403
+ const n = outcome.files.length;
404
+ const count = `${n} file${n === 1 ? "" : "s"}`;
405
+ const manifest =
406
+ outcome.newPaths.length === 0
407
+ ? `${count} (all modifications to tracked paths)`
408
+ : `${count}; new: ${outcome.newPaths.slice(0, 12).join(", ")}${
409
+ outcome.newPaths.length > 12 ? `, … +${outcome.newPaths.length - 12} more` : ""
410
+ }`;
411
+ return [where, manifest, kept];
403
412
  }
404
413
 
405
414
  /**
@@ -1255,7 +1264,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
1255
1264
  // is holding the merge button. The daemon still merges nothing itself.
1256
1265
  project.authority.merge === "orchestrator"
1257
1266
  ? "You never edit product code or push a branch — a worker session does that. Merging is yours: one PR at " +
1258
- "a time, freshness-checked against the base branch, per the Releases section of your ORCHESTRATOR.md."
1267
+ "a time, freshness-checked against the base branch, per the Releases section of your POLICY.md."
1259
1268
  : "You never edit product code, push a branch, or merge a PR — a worker session edits and pushes, and a " +
1260
1269
  "human merges.",
1261
1270
  "Handle each escalation below before the next one.",
package/src/escalate.ts CHANGED
@@ -45,11 +45,21 @@ export interface Escalator {
45
45
  * send, turning a cosmetic problem into a lost escalation. Plain text is also
46
46
  * valid Markdown, so the same string renders fine as an issue comment.
47
47
  */
48
+ /**
49
+ * Fleet-scoped pages (integrity tripwire, spend-cap halt, stall) use issue `0`
50
+ * as a sentinel — there is no tracker issue. Rendering that as `#0` made
51
+ * Telegram pages and daemon logs look like a bug (#52). Keep the sentinel in
52
+ * the typed field; only the human-facing label changes here.
53
+ */
54
+ export function escalationIssueRef(issue: number): string {
55
+ return issue === 0 ? "fleet" : `#${issue}`;
56
+ }
57
+
48
58
  export function formatEscalation(e: Escalation, project: string): string {
49
59
  const lines = [
50
60
  `omp-conductor · tier ${e.tier} escalation`,
51
61
  `project: ${project}`,
52
- `issue: #${e.issue}`,
62
+ `issue: ${escalationIssueRef(e.issue)}`,
53
63
  `summary: ${e.summary}`,
54
64
  ];
55
65
  if (e.detail) lines.push(`detail: ${e.detail}`);
@@ -99,18 +109,21 @@ export function createEscalator(
99
109
  const onTurnFailed = async (cause: unknown): Promise<void> => {
100
110
  if (!p.escalation.fallbackToIssueComment) {
101
111
  warn(
102
- `tier 1 escalation on issue #${e.issue} was accepted by the orchestrator but its ` +
112
+ `tier 1 escalation on ${escalationIssueRef(e.issue)} was accepted by the orchestrator but its ` +
103
113
  `turn failed (${errText(cause)}), and no fallback is configured — left unmarked ` +
104
114
  `so the next tick retries`,
105
115
  );
106
116
  return;
107
117
  }
108
118
  try {
119
+ if (e.issue === 0) {
120
+ throw new Error("fleet-scoped escalation has no issue to comment on");
121
+ }
109
122
  await tracker.comment(e.issue, text);
110
123
  store.markNotified(key);
111
124
  } catch (err) {
112
125
  warn(
113
- `tier 1 escalation on issue #${e.issue} failed after acceptance ` +
126
+ `tier 1 escalation on ${escalationIssueRef(e.issue)} failed after acceptance ` +
114
127
  `(${errText(cause)}) and its issue-comment fallback failed too ` +
115
128
  `(${errText(err)}) — left unmarked so the next tick retries`,
116
129
  );
@@ -154,10 +167,19 @@ export function createEscalator(
154
167
  if (!p.escalation.fallbackToIssueComment) {
155
168
  throw new Error(
156
169
  `no escalation transport configured for project "${p.name}": tier ${e.tier} ` +
157
- `escalation on issue #${e.issue} (${e.summary}) could not be delivered — ` +
170
+ `escalation on ${escalationIssueRef(e.issue)} (${e.summary}) could not be delivered — ` +
158
171
  `set escalation.telegramChatId or escalation.fallbackToIssueComment`,
159
172
  );
160
173
  }
174
+ // Fleet pages (issue 0) have nowhere to comment. Falling through here used
175
+ // to call `issues/0/comments` and surface as a confusing "#0" delivery
176
+ // failure after Telegram had already been tried (#52).
177
+ if (e.issue === 0) {
178
+ throw new Error(
179
+ `fleet-scoped tier ${e.tier} escalation (${e.summary}) has no issue to comment on — ` +
180
+ `configure escalation.telegramChatId so integrity/spend/stall pages can reach an operator`,
181
+ );
182
+ }
161
183
  await tracker.comment(e.issue, text);
162
184
  store.markNotified(key);
163
185
  },