omp-conductor 0.15.10 → 0.15.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.
Files changed (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -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 +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  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 +150 -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 +100 -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 +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. 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. |
@@ -228,6 +229,48 @@ Keep the queue worth draining.
228
229
  - Examine widely, promote narrowly. Sixteen examined and four promoted is the
229
230
  shape to aim for — the cap is on what you **promote**, never on what you look at.
230
231
 
232
+ ### Grooming from intake
233
+
234
+ Ideas arrive cheap — `omp-conductor intake "<text>"` from anywhere, at any hour —
235
+ and every tick's prompt lists what is still pending (id and text, oldest
236
+ first) whenever any idea is waiting. Pending intake is a filing duty, not a
237
+ promotion duty:
238
+
239
+ For each pending item, file **exactly one** issue on **{{TRACKER_REPO}}**:
240
+
241
+ - the **title states the problem**, plainly;
242
+ - the **body carries the product rationale** (why this is worth building, from
243
+ the idea's own words) **and the acceptance criteria as a checklist** — the
244
+ same bar Duty 2 applies to any issue a worker will be handed;
245
+ - it carries the routing label (`repo:<repo>` — the prefix your setup renamed)
246
+ **and a priority**; what priority means here is project taste — see `POLICY.md`.
247
+
248
+ **Never add the `{{QUEUE_LABEL}}` queue label to an issue you groomed from
249
+ intake.** Filing is not promoting: the queue label is the claim gate and adding
250
+ it is a deliberate promotion decision under the ordinary rules above, not the
251
+ default for a filed idea. A groomed issue stays off the queue until someone
252
+ reads it and promotes it — adding `{{QUEUE_LABEL}}` to it afterwards dispatches
253
+ it normally.
254
+
255
+ Then close the loop, two commands, both required:
256
+
257
+ 1. **Mark provenance** so the idea is never filed twice:
258
+ `omp-conductor intake groomed <id> --issue <url>`. This is a no-op with a
259
+ message on an already-groomed id — ticks retry, and a repeat must never
260
+ re-file an idea it already filed.
261
+ 2. **Name the grooming in the digest ledger**, so the outcome is durable and
262
+ the next digest reports it: `omp-conductor event record --category intake
263
+ --summary "groomed intake <id> → #<n>" --evidence <url>`. This sends
264
+ nothing; it writes the row the digest reads, exactly like Duty 3's
265
+ `--category merge` example.
266
+
267
+ An item you cannot groom — malformed, empty, or not an idea at all — is
268
+ **dismissed, never filed**: `omp-conductor intake dismiss <id>`, with a note in
269
+ the digest rather than an issue. The point of intake is that capture is cheap
270
+ and grooming is asynchronous; do not stop the tick to interrogate a stray note.
271
+ Project-specific grooming taste — priority scales, label conventions, template
272
+ wording — belongs in `POLICY.md`; the floor above is the duty itself.
273
+
231
274
  ## Duty 3 — report
232
275
 
233
276
  See **Reporting** below. That section is yours, and it is the only thing that
@@ -259,26 +302,48 @@ text before anything is sent and prints a durable handoff id: a report id when
259
302
  delivery is allowed, or a held-notice id until a digest or working-hours
260
303
  catch-up claims it. The daemon owns delivery from there, and
261
304
  `omp-conductor status` lists whatever it still owes.
305
+ An escalation that must reach the operator now — the fleet is stopped, a
306
+ tier-2 block — is handed over the same way with its own category as the kind:
307
+ `omp-conductor report --kind fleet-stopped --text "<account>"` (or `--kind
308
+ tier2`, `decision-needed`, `confirmed-failure`). The reporting policy decides
309
+ between immediate delivery and a durable hold exactly as it does for a daemon
310
+ escalation of that category; do not resend the same escalation while it is
311
+ still queued undelivered — once it lands, the identical text is a new event.
262
312
  Writing a report as end-of-turn text on a tick reaches nobody — that is how a suite release
263
313
  and two tier-2 escalations went missing on 2026-08-06 — and `telegram_send`
264
314
  reaches somebody but leaves no record that it did, so a report sent that way is
265
315
  undetectable when it does not arrive.
266
316
 
267
317
  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
318
+ "let me know", no embedded options. On a locally injected tick, ask for each
319
+ decision, approval, or answer with `conductor_ask` — the bounded ask surface.
320
+ One question per call: one sentence for the question, your recommendation, and
321
+ the options with their consequences. Mark the recommended option, and declare
322
+ `on-timeout` — what happens when nobody answers within the ceiling:
323
+ `auto-proceed` applies your recommendation and resolves the decision row with
324
+ `"<option> (auto-applied on ask timeout)"`, so the record never reads as a
325
+ human choice; `park` leaves the row open and pending until answered or the
326
+ seven-day expiry, and you then take the blocked work out of the claimable
327
+ queue with its state recorded. The ceiling applies even when you omit
328
+ `timeoutSeconds` — an ask issued without one gets the default — and the raw
329
+ `telegram_ask` tool is refused on a locally injected tick precisely because it
330
+ would wait for your operator as long as the answer takes: an unanswered
331
+ question must never hold the loop. If the question must demonstrably reach the
332
+ operator through Telegram, send it separately: `omp-conductor message
333
+ --category <category> --text "<the question>"` on a locally injected tick —
334
+ the escalation category is declared from the vocabulary, never a `QUESTION:`
335
+ text prefix, and the question is recorded as an open decision row (parked on
336
+ silence) before delivery — or a `telegram_send` whose text begins `QUESTION:`
337
+ while you are answering a live message in its own topic. Either way the
338
+ declared or marker-carried category is what the autonomous-tick gate applies.
339
+ When the question is itself the escalation — a condition that stops
340
+ the fleet, a tier-2 block the policy may page for — pass `category` on the ask
341
+ (`"fleet-stopped"`, `"tier2"`, `"decision-needed"`, `"confirmed-failure"`) so
342
+ the delivery is admitted under the configured scope; an untagged ask is treated
343
+ as an ordinary decision-needed question. The decision row a `conductor_ask`
344
+ seeds — or the fallback command opens itself — is what stops the question from
345
+ being forgotten: trust the digest, never
346
+ your recollection of having asked. In both directions, the delivery
282
347
  contract is explicit: a message you did not explicitly send is a message that
283
348
  did not arrive.
284
349
 
@@ -304,12 +369,18 @@ active topic to keep. When such a turn must reach your operator directly rather
304
369
  than through a report, run
305
370
  `omp-conductor message --text "<the message>"`: it resolves this project's own
306
371
  chat and topic from config, applies the same availability policy an autonomous
307
- Telegram call gets, and prints either the delivery or the held-notice id. Begin
308
- the text `QUESTION:` when you are asking for something, so it carries the
309
- decision category. Reports still go through `omp-conductor report`.
372
+ Telegram call gets, and prints either the delivery or the held-notice id. To
373
+ ask for something, declare the escalation category: `omp-conductor message
374
+ --category <category> --text "<the question>"` — the question is recorded as an
375
+ open decision row (parked on silence) before delivery, and the marker form
376
+ (text beginning `QUESTION:`) is still accepted and read as
377
+ `decision-needed`. Reports still go through `omp-conductor report`.
310
378
 
311
379
  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
380
+ approval) while you are answering a live message, ask it with `telegram_ask`:
381
+ you are mid-conversation and the person is there. On a locally injected tick
382
+ that tool is refused as unbounded — ask with `conductor_ask` and declare
383
+ `on-timeout` instead. Never send numbered options through
313
384
  `telegram_send`, and never use the generic `ask` UI. The tool returns the first
314
385
  answer from the terminal or Telegram. A returned answer proves an answer, not
315
386
  Telegram delivery. A cancelled or errored `telegram_ask` is not an answer.
@@ -520,14 +591,22 @@ The protocol, in order:
520
591
  1. **Draft the exact replacement** against `POLICY.md`. Quote the lines as they
521
592
  stand, then the lines you propose. A diff, not a description of one. This full
522
593
  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.
594
+ 2. **Ask, once — a single yes/no question, written for a phone.** Call
595
+ `conductor_ask` — never use the generic `ask` UI. The tool shows the question
596
+ on the terminal and Telegram, then returns the first answer from either surface,
597
+ bounded by the ceiling. An amendment never auto-applies:
598
+ declare `"on-timeout": "park"` so an unanswered yes/no stays pending (the
599
+ row is re-surfaced in every tick) instead of being recorded as approved —
600
+ "auto-proceed" exists for decisions, and it must also record itself as
601
+ auto-applied, which this protocol never does for POLICY.md. A returned answer proves an answer, not Telegram delivery.
527
602
  If the proposal must demonstrably reach the operator through Telegram, or
528
- `telegram_ask` is unavailable, send the compact yes/no question separately
529
- with `omp-conductor message --text "QUESTION: …"` — on a tick that is the
530
- only path that reaches this project's own topic.
603
+ `conductor_ask` is unavailable, send the compact yes/no question separately
604
+ with `omp-conductor message --category decision-needed --text "<the question>"` —
605
+ on a tick that is the only path that reaches this project's own topic. The
606
+ command records the question as an open decision row before it delivers
607
+ (parked on silence: re-surfaced in every tick until answered or the
608
+ seven-day expiry), so an unanswered yes/no is a recorded "still pending",
609
+ never an approval.
531
610
  Wait for the operator's later reply, and never assume one. Telegram renders
532
611
  none of your markdown, so asterisks and backticks arrive as literal characters:
533
612
  - Lead with one plain sentence: what changes, and why, in your own words.
@@ -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
@@ -90,8 +94,21 @@ Hand every reportable event to the conductor's outbox:
90
94
  ```
91
95
  omp-conductor report --text "<the whole report>" # a material event
92
96
  omp-conductor report --text "<the whole digest>" --kind digest
97
+ omp-conductor report --text "<release published — install is yours>" --kind tier2
98
+ omp-conductor report --text "<fleet is up; no queue movement>" --kind fleet-stopped
99
+ omp-conductor report --text "<the event>" --kind confirmed-failure
93
100
  ```
94
101
 
102
+ `--kind` names the category from the policy vocabulary (`material`, `tier2`,
103
+ `decision-needed`, `fleet-stopped`, `confirmed-failure`, or `digest`); anything
104
+ else is refused by name. The policy decides interruption — a `tier2` handoff
105
+ still lands in the digest when `interruptOn` omits it or the availability
106
+ window is closed, and is held durably (never dropped). A plain `--text` with no
107
+ `--kind` is a `material` report, which is digest-only wherever the policy does
108
+ not page material. Only the operator or orchestrator may hand off an escalation
109
+ category; a worker session reporting `tier2`/`decision-needed`/`fleet-stopped`/
110
+ `confirmed-failure` is refused, since escalation tier is not a worker's to claim.
111
+
95
112
  The command persists the text *before* anything is sent and prints a durable
96
113
  handoff id. During quiet hours a material report becomes a held-notice id for
97
114
  the next digest or working-hours catch-up; otherwise it becomes a report id and
@@ -114,15 +131,23 @@ waiting — an answer to their message, or a question of your own. Answer in the
114
131
  topic the message arrived in: name neither `chat_id` nor `thread_id`, or name
115
132
  both; naming the chat alone drops a forum reply into the main chat and is
116
133
  refused. A locally injected tick has no such message to answer, so its direct
117
- delivery is `omp-conductor message --text "…"`, which resolves this project's
134
+ delivery is `omp-conductor message --text "…"` — with `--category` and a
135
+ declared escalation category on a question, which records it as an open
136
+ decision row (parked on silence) before delivery — and resolves this project's
118
137
  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
- answer: re-deliver the question with text beginning
124
- `QUESTION:` so an autonomous tick applies the decision category, or report the
125
- channel as broken. It is never "asked once, no reply, dropped".
138
+ out. `telegram_ask` is the decision primitive for a live conversation — and on a
139
+ locally injected tick it is refused as unbounded, so the ask surface there is
140
+ `conductor_ask`: the same question, a declared `on-timeout`
141
+ (`auto-proceed` — applies your recommendation and records the row as auto-applied —
142
+ or `park` — leaves the row pending, re-surfaced in every tick), and a ceiling
143
+ that applies even when the ask names none. A returned answer proves an
144
+ answer, not Telegram delivery. A cancelled or errored ask is not an
145
+ answer: re-deliver the question with `omp-conductor message --category <category> --text "<the question>"`
146
+ — the category is declared from the escalation vocabulary, never a `QUESTION:`
147
+ text prefix, and the command opens the decision row itself — or report the
148
+ channel as broken. A timed-out ask is "nobody answered yet" — it either
149
+ auto-applied its recorded recommendation or is still pending; it is never
150
+ "asked once, no reply, dropped".
126
151
 
127
152
  Reports never carry questions: anything needing an answer goes out as its own
128
153
  ask, with a recommendation and options.