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.
- package/README.md +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -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 +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- 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 +49 -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 +98 -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 +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -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 +23 -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 +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -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 +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- 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. |
|
|
@@ -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.
|
|
269
|
-
approval, or answer
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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.**
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
`
|
|
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
|
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
|
|
@@ -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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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.
|
package/src/briefs/worker.md
CHANGED
|
@@ -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: <
|
|
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.
|
package/src/chain-check.ts
CHANGED
|
@@ -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
|
+
}
|