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.
- package/README.md +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- 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
|
-
:
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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 =
|
|
1247
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
|
|
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) => {
|
package/src/brief-upgrade.ts
CHANGED
|
@@ -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
|
-
/**
|
|
287
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
143
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
to notice
|
|
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
|
-
| `
|
|
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.
|
|
269
|
-
approval, or answer
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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.**
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
`
|
|
529
|
-
with `omp-conductor message --text "
|
|
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.
|
package/src/briefs/policy.md
CHANGED
|
@@ -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 "…"
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|