omp-conductor 0.15.9 → 0.15.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/board.ts CHANGED
@@ -21,6 +21,7 @@ import { makeTracker } from "./tracker/github.ts";
21
21
  import { planUsageBadge, readPlanUsage, sharedUsageSource } from "./usage.ts";
22
22
  import type {
23
23
  AdmissionHoldReason,
24
+ Caps,
24
25
  PrVerification,
25
26
  ProjectConfig,
26
27
  ReadyIssue,
@@ -730,7 +731,9 @@ function admissionLine(snapshot: BoardSnapshot): string {
730
731
  const queue =
731
732
  dispatch === undefined
732
733
  ? "dispatch not recorded"
733
- : `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.claimed ?? 0} in flight · ${dispatch.routed} spare · ${dispatch.admitted} admitted`;
734
+ : dispatch.paused === true
735
+ ? `held pass — nothing admitted${(dispatch.settled ?? 0) > 0 ? ` · settled ${dispatch.settled}` : ""}`
736
+ : `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.claimed ?? 0} in flight · ${dispatch.routed} spare · ${dispatch.admitted} admitted`;
734
737
  const holdText = dispatch?.holds.map((hold) => `${hold.reason} ${hold.count}`).join(", ");
735
738
  const holds = holdText === undefined || holdText === "" ? "none" : holdText;
736
739
  return (
@@ -1177,14 +1180,57 @@ export function boardJson(snapshot: BoardSnapshot): string {
1177
1180
  );
1178
1181
  }
1179
1182
 
1183
+ export interface BoardConfigState {
1184
+ project: ProjectConfig;
1185
+ caps: Caps;
1186
+ /** The last refresh could not read the config; `project`/`caps` are the last
1187
+ * good values and the board renders the failure instead of the values. */
1188
+ error?: string;
1189
+ /** Whether the last successful refresh resolved different values. */
1190
+ changed: boolean;
1191
+ }
1192
+
1193
+ /**
1194
+ * Re-resolve the board's project and caps from the config, once per refresh
1195
+ * cycle (#370). The board renders `project` and `caps` on every repaint, so
1196
+ * this read — never the render path — is what keeps a cap changed while the
1197
+ * board is open from going stale. A config that cannot be read returns the
1198
+ * previous values with the failure, exactly how the daemon holds boot values
1199
+ * across a failed hot reload instead of wedging its tick.
1200
+ */
1201
+ export function reloadBoardConfig(
1202
+ projectName: string | undefined,
1203
+ previous?: { project: ProjectConfig; caps: Caps },
1204
+ ): BoardConfigState {
1205
+ try {
1206
+ const cfg = loadConfig();
1207
+ const project = findProject(cfg, projectName);
1208
+ const caps = resolveCaps(project, cfg.defaults);
1209
+ const unchanged =
1210
+ previous !== undefined &&
1211
+ JSON.stringify(project) === JSON.stringify(previous.project) &&
1212
+ JSON.stringify(caps) === JSON.stringify(previous.caps);
1213
+ return { project, caps, changed: !unchanged };
1214
+ } catch (err) {
1215
+ if (previous === undefined) throw err;
1216
+ return {
1217
+ project: previous.project,
1218
+ caps: previous.caps,
1219
+ changed: false,
1220
+ error: err instanceof Error ? err.message : String(err),
1221
+ };
1222
+ }
1223
+ }
1224
+
1180
1225
  export async function runBoard(projectName?: string): Promise<void> {
1181
1226
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1182
1227
  throw new Error("board needs an interactive terminal (TTY)");
1183
1228
  }
1184
1229
 
1185
- const cfg = loadConfig();
1186
- const project = findProject(cfg, projectName);
1187
- const caps = resolveCaps(project, cfg.defaults);
1230
+ // The config is read at boot (an unreadable one still fails the board), then
1231
+ // re-read on the health cadence below so the board tracks what the daemon
1232
+ // hot-reloads per tick.
1233
+ let { project, caps, error: configError } = reloadBoardConfig(projectName);
1188
1234
  const store: Store = openStore(dbPath());
1189
1235
  const cursor: BoardCursor = { column: 2, card: 0, detail: false, transcriptOffset: 0 };
1190
1236
  const queue: KeyInput[] = [];
@@ -1243,18 +1289,30 @@ export async function runBoard(projectName?: string): Promise<void> {
1243
1289
  const now = Date.now();
1244
1290
  if (now - healthAt >= HEALTH_REFRESH_MS && healthRefresh === undefined) {
1245
1291
  healthAt = now;
1246
- healthRefresh = Promise.all([
1247
- probeBoardHealth(project),
1292
+ healthRefresh = (async () => {
1293
+ // #370: the config is re-read on the health cadence, not the 1 Hz
1294
+ // repaint — the daemon hot-reloads per tick, so a cap changed while
1295
+ // the board is open must land here too. A config that cannot be read
1296
+ // keeps the last good project/caps and marks the board stale instead
1297
+ // of tearing it down: the daemon's own boot-values rule.
1298
+ const reloaded = reloadBoardConfig(projectName, { project, caps });
1248
1299
  // On the health cadence, not the 1s repaint: the provider read is a
1249
1300
  // subprocess, and an allowance does not move at 1 Hz.
1250
- readPlanUsage(caps.planUsage, sharedUsageSource()),
1251
- ])
1252
- .then(([nextProbe, nextPlanUsage]) => {
1253
- health = nextProbe.health;
1254
- pausedPhases = nextProbe.pausedPhases;
1255
- planUsage = nextPlanUsage;
1256
- enqueue(queue, { name: "refresh" }, wake);
1257
- })
1301
+ const [nextProbe, nextPlanUsage] = await Promise.all([
1302
+ probeBoardHealth(reloaded.project),
1303
+ readPlanUsage(reloaded.caps.planUsage, sharedUsageSource()),
1304
+ ]);
1305
+ project = reloaded.project;
1306
+ caps = reloaded.caps;
1307
+ configError = reloaded.error;
1308
+ health = nextProbe.health;
1309
+ pausedPhases = nextProbe.pausedPhases;
1310
+ planUsage = nextPlanUsage;
1311
+ if (reloaded.error === undefined && reloaded.changed) {
1312
+ notice = "config reloaded - changes applied";
1313
+ }
1314
+ enqueue(queue, { name: "refresh" }, wake);
1315
+ })()
1258
1316
  .catch((err: unknown) => {
1259
1317
  notice = `health refresh failed: ${err instanceof Error ? err.message : String(err)}`;
1260
1318
  })
@@ -1313,7 +1371,15 @@ export async function runBoard(projectName?: string): Promise<void> {
1313
1371
  now,
1314
1372
  };
1315
1373
  normalizeCursor(snapshot, cursor);
1316
- process.stdout.write(`${CSI}H${renderBoard(snapshot, cursor, process.stdout.columns, process.stdout.rows, notice, help)}${CSI}J`);
1374
+ // An unreadable config pins the footer to the last-good warning for as
1375
+ // long as the config stays broken, instead of clearing after the one
1376
+ // frame a transient notice gets (#370); the cap on screen is last good
1377
+ // and says so. Transient notices get their slot back on a good read.
1378
+ const frameNotice =
1379
+ configError === undefined
1380
+ ? notice
1381
+ : `config unreadable — ${configError} — showing last good values`;
1382
+ process.stdout.write(`${CSI}H${renderBoard(snapshot, cursor, process.stdout.columns, process.stdout.rows, frameNotice, help)}${CSI}J`);
1317
1383
  notice = "";
1318
1384
 
1319
1385
  const key = await waitForInput(queue, (next) => {
@@ -34,6 +34,14 @@ export const POLICY_BRIEF_NAME = "POLICY.md";
34
34
  /** Composed view the session / AGENTS.md symlink historically pointed at. */
35
35
  export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
36
36
 
37
+ /**
38
+ * Host-wide operator policy, one file applying to every project on this host.
39
+ *
40
+ * Lives beside `config.json` under the state root (see {@link sharedPolicyPath})
41
+ * because it is operator text that outlives any one project's workspace.
42
+ */
43
+ export const SHARED_POLICY_BRIEF_NAME = "SHARED_POLICY.md";
44
+
37
45
  /** Topic keys that belong in POLICY.md (matched like {@link topicKey}). */
38
46
  export const OWNED_TOPIC_KEYS = ["releases", "project context", "reporting", "amendments"] as const;
39
47
 
@@ -46,6 +54,39 @@ export const COMPOSE_BANNER = [
46
54
  "<!-- ==================================================================== -->",
47
55
  ].join("\n");
48
56
 
57
+ /**
58
+ * Banner for the three-layer compose: names every source so a reader can tell
59
+ * which layer a paragraph came from, and states the precedence — the project's
60
+ * own `POLICY.md` overrides the host-wide shared layer where they conflict.
61
+ *
62
+ * Only used when a shared policy actually exists. With none, composition is
63
+ * byte-identical to the two-layer `COMPOSE_BANNER` form above (#363).
64
+ */
65
+ export const COMPOSE_BANNER_WITH_SHARED = [
66
+ "<!-- ==================================================================== -->",
67
+ "<!-- YOURS TO EDIT — live copy of POLICY.md. Edit POLICY.md, not here. -->",
68
+ "<!-- This composed ORCHESTRATOR.md is regenerated from package floor + -->",
69
+ "<!-- SHARED_POLICY.md + POLICY.md; hand-edits above or below the -->",
70
+ "<!-- banners will not last. -->",
71
+ "<!-- -->",
72
+ "<!-- Shared operator policy (SHARED_POLICY.md beside config.json) -->",
73
+ "<!-- applies to every project on this host. The project's POLICY.md -->",
74
+ "<!-- below overrides it where the two conflict. -->",
75
+ "<!-- ==================================================================== -->",
76
+ ].join("\n");
77
+
78
+ /**
79
+ * Divider between the shared layer and the project's own `POLICY.md` half.
80
+ *
81
+ * Carries its own edit marker so the two operator layers each end at a "yours
82
+ * to edit" boundary instead of running into each other.
83
+ */
84
+ export const PROJECT_POLICY_DIVIDER = [
85
+ "<!-- ==================================================================== -->",
86
+ "<!-- YOURS TO EDIT — live copy of the project's POLICY.md below. -->",
87
+ "<!-- ==================================================================== -->",
88
+ ].join("\n");
89
+
49
90
  /**
50
91
  * A brief split into the package's half and the operator's half.
51
92
  *
@@ -255,6 +296,32 @@ export function orchestratorPathForRoot(workspaceRoot: string): string {
255
296
  return join(workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
256
297
  }
257
298
 
299
+ /** Where the host-wide shared policy lives: beside `config.json` (see stateDir). */
300
+ export function sharedPolicyPath(): string {
301
+ return join(stateDir(), SHARED_POLICY_BRIEF_NAME);
302
+ }
303
+
304
+ /**
305
+ * Shared policy text from the host-wide file, or `undefined` when it is absent.
306
+ *
307
+ * `undefined` is the "two-layer fleet" answer: absent shared policy composes
308
+ * byte-identically to today, so callers pass this straight into
309
+ * {@link composeOrchestrator}.
310
+ */
311
+ export function readSharedPolicy(): string | undefined {
312
+ const path = sharedPolicyPath();
313
+ return existsSync(path) ? readFileSync(path, "utf8") : undefined;
314
+ }
315
+
316
+ /**
317
+ * Shared policy text from an explicitly-named file, or `undefined` when the
318
+ * file does not exist. The refresh/migrate paths take the path as an option so
319
+ * their tests stay hermetic; {@link readSharedPolicy} is the state-dir version.
320
+ */
321
+ function readSharedAt(path: string | undefined): string | undefined {
322
+ return path !== undefined && existsSync(path) ? readFileSync(path, "utf8") : undefined;
323
+ }
324
+
258
325
  /**
259
326
  * Classifies what sits in the workspace: overlay, migratable legacy, or absent.
260
327
  *
@@ -283,11 +350,19 @@ export function inspectBriefLayout(
283
350
  };
284
351
  }
285
352
 
286
- /** Join rendered floor + live policy into the composed session brief. */
287
- export function composeOrchestrator(floor: string, policy: string): string {
353
+ /**
354
+ * Join rendered floor + live policy into the composed session brief.
355
+ *
356
+ * An optional host-wide shared policy composes between the floor and the
357
+ * project policy. Absent one (`undefined`), the output is byte-identical to
358
+ * the two-layer form — a single-project fleet sees no change at all.
359
+ */
360
+ export function composeOrchestrator(floor: string, policy: string, sharedPolicy?: string): string {
288
361
  const f = floor.replace(/\s+$/, "\n");
289
362
  const p = policy.replace(/^\s+/, "").replace(/\s+$/, "\n");
290
- return `${f}\n${COMPOSE_BANNER}\n\n${p}`;
363
+ if (sharedPolicy === undefined) return `${f}\n${COMPOSE_BANNER}\n\n${p}`;
364
+ const s = sharedPolicy.replace(/^\s+/, "").replace(/\s+$/, "\n");
365
+ return `${f}\n${COMPOSE_BANNER_WITH_SHARED}\n\n${s}\n${PROJECT_POLICY_DIVIDER}\n\n${p}`;
291
366
  }
292
367
 
293
368
  /**
@@ -370,6 +445,8 @@ export function migrateToPolicy(opts: {
370
445
  floor: string;
371
446
  /** When set, use this owned text instead of splitting the live file. */
372
447
  owned?: string;
448
+ /** Optional host-wide shared policy file; absent one composes two-layer. */
449
+ sharedPolicyPath?: string;
373
450
  /** Override the conductor backup directory (tests). */
374
451
  backupRoot?: string;
375
452
  }): MigrateResult {
@@ -386,7 +463,11 @@ export function migrateToPolicy(opts: {
386
463
  policyBody.endsWith("\n") ? policyBody : `${policyBody}\n`,
387
464
  opts.backupRoot,
388
465
  );
389
- const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
466
+ const composed = composeOrchestrator(
467
+ opts.floor,
468
+ readFileSync(opts.policyPath, "utf8"),
469
+ readSharedAt(opts.sharedPolicyPath),
470
+ );
390
471
  const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed, opts.backupRoot);
391
472
  return {
392
473
  policyPath: opts.policyPath,
@@ -407,6 +488,8 @@ export function repairPolicyBannerCrumbs(opts: {
407
488
  orchestratorPath: string;
408
489
  policyPath: string;
409
490
  floor: string;
491
+ /** Optional host-wide shared policy file; absent one composes two-layer. */
492
+ sharedPolicyPath?: string;
410
493
  /** Override the conductor backup directory (tests). */
411
494
  backupRoot?: string;
412
495
  }): MigrateResult | undefined {
@@ -415,7 +498,10 @@ export function repairPolicyBannerCrumbs(opts: {
415
498
  const cleaned = stripLeadingBannerCrumbs(before);
416
499
  if (cleaned === before) {
417
500
  // Still recompose so the floor matches this package even when POLICY was clean.
418
- writeFileSync(opts.orchestratorPath, composeOrchestrator(opts.floor, before));
501
+ writeFileSync(
502
+ opts.orchestratorPath,
503
+ composeOrchestrator(opts.floor, before, readSharedAt(opts.sharedPolicyPath)),
504
+ );
419
505
  return undefined;
420
506
  }
421
507
  const policyBackup = writeWithBackup(
@@ -423,7 +509,11 @@ export function repairPolicyBannerCrumbs(opts: {
423
509
  cleaned.endsWith("\n") ? cleaned : `${cleaned}\n`,
424
510
  opts.backupRoot,
425
511
  );
426
- const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
512
+ const composed = composeOrchestrator(
513
+ opts.floor,
514
+ readFileSync(opts.policyPath, "utf8"),
515
+ readSharedAt(opts.sharedPolicyPath),
516
+ );
427
517
  const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed, opts.backupRoot);
428
518
  return {
429
519
  policyPath: opts.policyPath,
@@ -442,13 +532,18 @@ export function refreshComposedBrief(opts: {
442
532
  orchestratorPath: string;
443
533
  policyPath: string;
444
534
  floor: string;
535
+ /** Optional host-wide shared policy file; absent one composes two-layer. */
536
+ sharedPolicyPath?: string;
445
537
  /** Override the conductor backup directory (tests). */
446
538
  backupRoot?: string;
447
539
  }): boolean {
448
540
  migrateLegacyBriefBackups([opts.policyPath, opts.orchestratorPath], opts.backupRoot);
449
541
  if (!existsSync(opts.policyPath)) return false;
450
542
  const policy = readFileSync(opts.policyPath, "utf8");
451
- writeFileSync(opts.orchestratorPath, composeOrchestrator(opts.floor, policy));
543
+ writeFileSync(
544
+ opts.orchestratorPath,
545
+ composeOrchestrator(opts.floor, policy, readSharedAt(opts.sharedPolicyPath)),
546
+ );
452
547
  return true;
453
548
  }
454
549
 
@@ -557,13 +652,24 @@ export function applyRetrofit(path: string, proposal: RetrofitProposal, backupRo
557
652
  * is now the only classifier. The pre-overlay pair it replaced described two
558
653
  * further states ("mergeable", "current") that only the deleted single-file
559
654
  * merge could act on (#131).
655
+ *
656
+ * `sharedPolicyPath` is the optional host-wide shared-policy file; the overlay
657
+ * report lists it when present so the operator can see which layers compose.
560
658
  */
561
- export function formatBriefReport(path: string, layout: BriefLayout, missing: readonly string[]): string {
659
+ export function formatBriefReport(
660
+ path: string,
661
+ layout: BriefLayout,
662
+ missing: readonly string[],
663
+ sharedPolicyPath?: string,
664
+ ): string {
562
665
  if (layout.kind === "overlay") {
563
666
  return [
564
667
  `brief overlay active`,
565
668
  "",
566
669
  ` floor package template → recomposed into ${layout.orchestratorPath} each tick`,
670
+ ` shared ${
671
+ sharedPolicyPath ?? "(none — composing package floor + POLICY.md)"
672
+ } — host-wide operator policy; the project's POLICY.md overrides it`,
567
673
  ` policy ${layout.policyPath} (Learning loop / operator edits)`,
568
674
  "",
569
675
  "Protocol updates: upgrade omp-conductor in this host's existing install root (same package manager), then restart the daemon — no brief-upgrade --apply.",
@@ -139,24 +139,25 @@ Never leave an orphan holding a slot "to be safe": a label nobody is working und
139
139
  is not safety, it is a deadlocked fleet that looks busy.
140
140
 
141
141
  **Then read the settlement flags.** The conductor no longer takes a worker's word
142
- for what it changed. When a run settles, its report's `changed:` line is
143
- reconciled against the pull request's actual diff, and the diff is checked for
142
+ for what it changed — it does not even ask. When a run settles, the `changed:`
143
+ line of its report is **derived from the pull request's own diff**: the worker
144
+ writes the narrative, the daemon writes the file list, so an undisclosed file
145
+ cannot exist as a concept. What the audit checks instead is the diff itself, for
144
146
  weakened tests. Anything it finds is a **settlement audit** flag: it appears
145
147
  under the run in `omp-conductor status`, in the settlement report, and — once,
146
148
  at settlement — as a tier-1 escalation to you.
147
149
 
148
150
  A flag is evidence, not a verdict. It never blocks anything: the run settled
149
151
  normally, the PR is open and mergeable, and nothing is waiting on you. What it
150
- says is that one specific sentence in the worker's own account of its work did
151
- not survive contact with the diff, and that a human's usual review would have
152
- to notice it unaided.
152
+ says is that something specific about the run did not survive contact with the
153
+ diff — above all a weakened test, the one thing a reviewer's usual pass is
154
+ poorly placed to notice. (The file list is derived, so the only disclosure flag
155
+ left is a settlement that could not read the PR at all — read its file list
156
+ straight from GitHub.)
153
157
 
154
158
  | Flag | What it means | What it invites |
155
159
  | --- | --- | --- |
156
- | `undisclosed-file` | The PR touched a file the report never mentioned. | Open the diff for that file. An undisclosed edit is usually incidental — a lockfile, a formatter — and occasionally the whole story. |
157
- | `changed-line-missing` | The report disclosed nothing at all. | Read the diff before merging; you have no summary of it. |
158
- | `unmatched-claim` | The report named a file the PR never touched. | Weak on its own. Two or three together mean the report was written from memory rather than from `git diff`, so trust the rest of it less. |
159
- | `report-format-unparsed` | The audit could not parse the report's format. | Not a trust signal against the worker — read the diff directly. |
160
+ | `changed-line-missing` | The settlement could not read the PR's diff, so no file list was derived. | The one disclosure fault left, and it is a tree-read failure, not a worker's: read the PR's file list directly. |
160
161
  | `test-file-deleted` | A test file left the tree and no rename explains it. | The one that most deserves a human. Was the behaviour it defended deleted too, or only its test? |
161
162
  | `test-disabled` | A `.skip` / `.only` / `xit` / `t.Skip` marker was added. | Ask what turned red. A skip added in the same PR as the change it stopped failing is the shape to look for. |
162
163
  | `assertions-removed` | Assertions were commented out, or more left a file than entered it. | Compare against the issue's acceptance criteria: an assertion removed because the spec changed is fine, one removed because it failed is not. |
@@ -259,26 +260,44 @@ text before anything is sent and prints a durable handoff id: a report id when
259
260
  delivery is allowed, or a held-notice id until a digest or working-hours
260
261
  catch-up claims it. The daemon owns delivery from there, and
261
262
  `omp-conductor status` lists whatever it still owes.
263
+ An escalation that must reach the operator now — the fleet is stopped, a
264
+ tier-2 block — is handed over the same way with its own category as the kind:
265
+ `omp-conductor report --kind fleet-stopped --text "<account>"` (or `--kind
266
+ tier2`, `decision-needed`, `confirmed-failure`). The reporting policy decides
267
+ between immediate delivery and a durable hold exactly as it does for a daemon
268
+ escalation of that category; do not resend the same escalation while it is
269
+ still queued undelivered — once it lands, the identical text is a new event.
262
270
  Writing a report as end-of-turn text on a tick reaches nobody — that is how a suite release
263
271
  and two tier-2 escalations went missing on 2026-08-06 — and `telegram_send`
264
272
  reaches somebody but leaves no record that it did, so a report sent that way is
265
273
  undetectable when it does not arrive.
266
274
 
267
275
  A report is an update. It never contains a request: no "needs you" header, no
268
- "let me know", no embedded options. Use `telegram_ask` for each decision,
269
- approval, or answer that you need. Include one sentence for the question, your
270
- recommendation, and the options with their consequences. Mark the recommended
271
- option. Batch several questions into one ask (the surface takes up to five);
272
- never make one call per question, and never type a numbered menu into a plain
273
- message. The tool shows each question on the terminal and Telegram, then accepts
274
- the first answer from either surface. A returned answer proves an answer, not
275
- Telegram delivery. If the question must demonstrably reach the operator through
276
- Telegram, send it separately: `omp-conductor message --text "QUESTION: …"` on a
277
- locally injected tick, or a `telegram_send` whose text begins `QUESTION:` while
278
- you are answering a live message in its own topic. Either way the marker is what
279
- makes the autonomous-tick gate apply the decision category. Still
280
- open a `decision` row for anything you ask: the ask collects the answer, and the
281
- row stops it from being forgotten. In both directions, the delivery
276
+ "let me know", no embedded options. On a locally injected tick, ask for each
277
+ decision, approval, or answer with `conductor_ask` — the bounded ask surface.
278
+ One question per call: one sentence for the question, your recommendation, and
279
+ the options with their consequences. Mark the recommended option, and declare
280
+ `on-timeout` — what happens when nobody answers within the ceiling:
281
+ `auto-proceed` applies your recommendation and resolves the decision row with
282
+ `"<option> (auto-applied on ask timeout)"`, so the record never reads as a
283
+ human choice; `park` leaves the row open and pending until answered or the
284
+ seven-day expiry, and you then take the blocked work out of the claimable
285
+ queue with its state recorded. The ceiling applies even when you omit
286
+ `timeoutSeconds` — an ask issued without one gets the default — and the raw
287
+ `telegram_ask` tool is refused on a locally injected tick precisely because it
288
+ would wait for your operator as long as the answer takes: an unanswered
289
+ question must never hold the loop. If the question must demonstrably reach the
290
+ operator through Telegram, send it separately: `omp-conductor message --text
291
+ "QUESTION: …"` on a locally injected tick, or a `telegram_send` whose text
292
+ begins `QUESTION:` while you are answering a live message in its own topic.
293
+ Either way the marker is what makes the autonomous-tick gate apply the decision
294
+ category. When the question is itself the escalation — a condition that stops
295
+ the fleet, a tier-2 block the policy may page for — pass `category` on the ask
296
+ (`"fleet-stopped"`, `"tier2"`, `"decision-needed"`, `"confirmed-failure"`) so
297
+ the delivery is admitted under the configured scope; an untagged ask is treated
298
+ as an ordinary decision-needed question. The decision row a `conductor_ask`
299
+ seeds is what stops the question from being forgotten: trust the digest, never
300
+ your recollection of having asked. In both directions, the delivery
282
301
  contract is explicit: a message you did not explicitly send is a message that
283
302
  did not arrive.
284
303
 
@@ -309,7 +328,10 @@ the text `QUESTION:` when you are asking for something, so it carries the
309
328
  decision category. Reports still go through `omp-conductor report`.
310
329
 
311
330
  If the answer needs a decision from the operator (a choice, a yes/no, or an
312
- approval), ask it with `telegram_ask`. Never send numbered options through
331
+ approval) while you are answering a live message, ask it with `telegram_ask`:
332
+ you are mid-conversation and the person is there. On a locally injected tick
333
+ that tool is refused as unbounded — ask with `conductor_ask` and declare
334
+ `on-timeout` instead. Never send numbered options through
313
335
  `telegram_send`, and never use the generic `ask` UI. The tool returns the first
314
336
  answer from the terminal or Telegram. A returned answer proves an answer, not
315
337
  Telegram delivery. A cancelled or errored `telegram_ask` is not an answer.
@@ -520,12 +542,16 @@ The protocol, in order:
520
542
  1. **Draft the exact replacement** against `POLICY.md`. Quote the lines as they
521
543
  stand, then the lines you propose. A diff, not a description of one. This full
522
544
  text is what you *apply* on a yes — it is not what you send.
523
- 2. **Ask, once — a single yes/no question, written for a phone.** Explicitly
524
- call `telegram_ask`; never use the generic `ask` UI. The tool shows the
525
- question on the terminal and Telegram, then returns the first answer from
526
- either surface. A returned answer proves an answer, not Telegram delivery.
545
+ 2. **Ask, once — a single yes/no question, written for a phone.** Call
546
+ `conductor_ask` — never use the generic `ask` UI. The tool shows the question
547
+ on the terminal and Telegram, then returns the first answer from either surface,
548
+ bounded by the ceiling. An amendment never auto-applies:
549
+ declare `"on-timeout": "park"` so an unanswered yes/no stays pending (the
550
+ row is re-surfaced in every tick) instead of being recorded as approved —
551
+ "auto-proceed" exists for decisions, and it must also record itself as
552
+ auto-applied, which this protocol never does for POLICY.md. A returned answer proves an answer, not Telegram delivery.
527
553
  If the proposal must demonstrably reach the operator through Telegram, or
528
- `telegram_ask` is unavailable, send the compact yes/no question separately
554
+ `conductor_ask` is unavailable, send the compact yes/no question separately
529
555
  with `omp-conductor message --text "QUESTION: …"` — on a tick that is the
530
556
  only path that reaches this project's own topic.
531
557
  Wait for the operator's later reply, and never assume one. Telegram renders
@@ -50,6 +50,10 @@ replaced while this text still reads plausible. If you find this section
50
50
  describing machinery the repo no longer has, that is a **Learning loop** trigger:
51
51
  propose the corrected steps.
52
52
 
53
+ ## Promotion — who adds `{{QUEUE_LABEL}}`
54
+
55
+ {{PROMOTION_DUTY}}
56
+
53
57
  ## Project context (filled during onboarding)
54
58
 
55
59
  Empty until setup fills it in: the product in a paragraph, a map of which repo
@@ -116,13 +120,18 @@ both; naming the chat alone drops a forum reply into the main chat and is
116
120
  refused. A locally injected tick has no such message to answer, so its direct
117
121
  delivery is `omp-conductor message --text "…"`, which resolves this project's
118
122
  own chat and topic. Neither is a report: they leave no record that anything went
119
- out. `telegram_ask` is the right
120
- call for a decision. It shows the question on the terminal and Telegram, then
121
- returns the first answer from either surface. A returned answer proves an
122
- answer, not Telegram delivery. A cancelled or errored `telegram_ask` is not an
123
+ out. `telegram_ask` is the decision primitive for a live conversation — and on a
124
+ locally injected tick it is refused as unbounded, so the ask surface there is
125
+ `conductor_ask`: the same question, a declared `on-timeout`
126
+ (`auto-proceed` — applies your recommendation and records the row as auto-applied —
127
+ or `park` — leaves the row pending, re-surfaced in every tick), and a ceiling
128
+ that applies even when the ask names none. A returned answer proves an
129
+ answer, not Telegram delivery. A cancelled or errored ask is not an
123
130
  answer: re-deliver the question with text beginning
124
131
  `QUESTION:` so an autonomous tick applies the decision category, or report the
125
- channel as broken. It is never "asked once, no reply, dropped".
132
+ channel as broken. A timed-out ask is "nobody answered yet" — it either
133
+ auto-applied its recorded recommendation or is still pending; it is never
134
+ "asked once, no reply, dropped".
126
135
 
127
136
  Reports never carry questions: anything needing an answer goes out as its own
128
137
  ask, with a recommendation and options.
@@ -213,10 +213,16 @@ pr: <url or "none">
213
213
  head: <40-character head SHA or "none">
214
214
  state: pushed-green | blocked | failed
215
215
  gates: <exact commands run and their results>
216
- changed: <files touched, one line>
216
+ changed: <the settlement derives this from the PR diff — omit the line>
217
217
  next: <nothing | the specific decision needed>
218
218
  ```
219
219
 
220
+ The `changed:` line is not yours to write from memory: the settlement replaces
221
+ it with the actual file list from the PR's diff. Omit it, or write it wrongly —
222
+ the settled report carries the diff's list either way. The narrative in your
223
+ report (what you changed and why, above these lines) is the part only you can
224
+ write, and it is the part a reviewer reads.
225
+
220
226
  Never report success you have not observed. "Should pass CI" is not a state, and
221
227
  `pushed-green` means you watched the checks go green — not that you expect them
222
228
  to.
@@ -154,4 +154,4 @@ export function chainViolations(input: ChainViolationInput): string[] {
154
154
  }
155
155
 
156
156
  return violations;
157
- }
157
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Gate: tracked text files must end with a final newline and no trailing
3
+ * blank lines.
4
+ *
5
+ * Run via `bun run check` from the package root (after `tsc --noEmit`).
6
+ * Neither tsc nor the test suite has an opinion about the last byte of a
7
+ * file, so worker-authored files kept landing without one — each a spurious
8
+ * two-line hunk on the next edit. This is the cheap mechanical check that
9
+ * closes that gap: it walks `git ls-files`, checks every tracked text file
10
+ * by extension, and names every offender in a single run.
11
+ */
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import { readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ /** Tracked text suffixes the gate applies to. */
18
+ export const INCLUDED_SUFFIXES: readonly string[] = [
19
+ ".ts", ".js", ".css", ".html", ".md", ".json", ".yml", ".yaml", ".toml", ".sh",
20
+ ];
21
+
22
+ /**
23
+ * Generated files the gate must not police, named explicitly rather than
24
+ * guessed at by content. Paths are repo-root-relative, as `git ls-files`
25
+ * reports them.
26
+ */
27
+ export const EXCLUDED_PATHS: readonly string[] = [
28
+ "omp/bun.lock", // regenerated by `bun install`
29
+ "omp/schema/config.schema.json", // regenerated by `bun run schema`
30
+ ];
31
+
32
+ /** Reason this file's ending fails the gate, or null when it is correct. */
33
+ export function checkEnding(bytes: Uint8Array): string | null {
34
+ let trailingNewlines = 0;
35
+ for (let i = bytes.length - 1; i >= 0 && bytes[i] === 0x0a; i--) {
36
+ trailingNewlines++;
37
+ }
38
+ if (trailingNewlines === 0) return "missing trailing newline";
39
+ if (trailingNewlines > 2) return `ends with ${trailingNewlines - 1} blank lines`;
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Check every file, returning one violation line per offender so a single
45
+ * run names every bad path instead of dying on the first.
46
+ */
47
+ export function findViolations(
48
+ paths: readonly string[],
49
+ read: (path: string) => Uint8Array = readFileSync,
50
+ ): string[] {
51
+ const violations: string[] = [];
52
+ for (const path of paths) {
53
+ const bytes = read(path);
54
+ if (bytes.length === 0) continue; // nothing to terminate
55
+ const reason = checkEnding(bytes);
56
+ if (reason !== null) violations.push(`${path}: ${reason}`);
57
+ }
58
+ return violations;
59
+ }
60
+
61
+ function repoRoot(): string {
62
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
63
+ }
64
+
65
+ if (import.meta.main) {
66
+ const root = repoRoot();
67
+ const tracked = execFileSync("git", ["-C", root, "ls-files"], { encoding: "utf8" })
68
+ .split("\n")
69
+ .filter((path) => path.length > 0);
70
+ const textFiles = tracked.filter(
71
+ (path) =>
72
+ INCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix)) &&
73
+ !EXCLUDED_PATHS.includes(path),
74
+ );
75
+ const violations = findViolations(textFiles, (path) => readFileSync(join(root, path)));
76
+ if (violations.length > 0) {
77
+ for (const violation of violations) console.error(violation);
78
+ console.error(`trailing newline gate: ${violations.length} file(s) need fixing`);
79
+ process.exit(1);
80
+ }
81
+ console.log("trailing newline gate: ok");
82
+ }