omp-conductor 0.13.0 → 0.15.0

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 (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
package/src/diff-flags.ts CHANGED
@@ -290,9 +290,19 @@ function expandBraces(token: string): string[] {
290
290
  return alternatives.map((alt) => prefix + alt + suffix);
291
291
  }
292
292
 
293
+ /** A trailing slash-separated extension list:
294
+ * `scripts/a.sh/.py` → [`scripts/a.sh`, `scripts/a.py`]. */
295
+ function expandExtensionAlternation(token: string): string[] {
296
+ const match = /^(.+?)\.([A-Za-z][A-Za-z0-9]{1,7})((?:\/\.[A-Za-z][A-Za-z0-9]{1,7})+)$/.exec(token);
297
+ if (match === null) return [token];
298
+ const [, base, first, rest] = match;
299
+ if (base === undefined || first === undefined || rest === undefined) return [token];
300
+ return [first, ...rest.split("/.").filter(Boolean)].map((extension) => `${base}.${extension}`);
301
+ }
302
+
293
303
  /** Every path-shaped token on the report's `changed:` line. An absent line and
294
304
  * a line naming nothing are the same answer: nothing was disclosed. */
295
- export function claimedPaths(report: string): string[] {
305
+ function parseClaimedPaths(report: string, extensionAlternatives?: Set<string>): string[] {
296
306
  const line = CHANGED_LINE.exec(report)?.[1] ?? "";
297
307
  const seen = new Set<string>();
298
308
  // Split on whitespace and semicolons, and on commas *outside* a brace group:
@@ -306,13 +316,21 @@ export function claimedPaths(report: string): string[] {
306
316
  // directions — sees plain paths: a brace token dies earlier at the
307
317
  // CLAIMED_PATH filter if it is never opened up (#224).
308
318
  for (const expanded of expandBraces(normalised)) {
309
- if (!CLAIMED_PATH.test(expanded) && !DOTTED_MODULE.test(expanded)) continue;
310
- seen.add(expanded);
319
+ const alternatives = expandExtensionAlternation(expanded);
320
+ for (const path of alternatives) {
321
+ if (!CLAIMED_PATH.test(path) && !DOTTED_MODULE.test(path)) continue;
322
+ seen.add(path);
323
+ if (alternatives.length > 1) extensionAlternatives?.add(path);
324
+ }
311
325
  }
312
326
  }
313
327
  return [...seen];
314
328
  }
315
329
 
330
+ export function claimedPaths(report: string): string[] {
331
+ return parseClaimedPaths(report);
332
+ }
333
+
316
334
  /**
317
335
  * Whether one claim covers one path. Every rule here is deliberately generous,
318
336
  * because each one that fails produces an omission flag on an honest report:
@@ -505,7 +523,8 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
505
523
 
506
524
  function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
507
525
  const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
508
- const claims = claimedPaths(audit.report);
526
+ const extensionAlternatives = new Set<string>();
527
+ const claims = parseClaimedPaths(audit.report, extensionAlternatives);
509
528
  const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
510
529
  // Coverage pools the current report with every prior attempt's disclosures:
511
530
  // a file the final report no longer names was disclosed while the work was
@@ -553,12 +572,62 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
553
572
  if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
554
573
  continue;
555
574
  }
575
+ if (extensionAlternatives.has(claim)) {
576
+ const stem = claim.slice(0, claim.lastIndexOf("."));
577
+ const matchedAlternative = claims.some(
578
+ (other) =>
579
+ other !== claim &&
580
+ extensionAlternatives.has(other) &&
581
+ other.slice(0, other.lastIndexOf(".")) === stem &&
582
+ audit.diff.files.some((f) => covers(other, f.path) || covers(other, f.previousPath ?? "")),
583
+ );
584
+ if (matchedAlternative) continue;
585
+ }
556
586
  flags.push({
557
587
  kind: "unmatched-claim",
558
588
  file: claim,
559
589
  detail: "named by the report's `changed:` line but not touched by the PR",
560
590
  });
561
591
  }
592
+
593
+ // A parser failure can otherwise accuse the report in both directions for
594
+ // the same file. Collapse only that self-refuting pair; unrelated findings
595
+ // remain intact.
596
+ const unmatched = flags.filter((flag) => flag.kind === "unmatched-claim");
597
+ const removed = new Set<SettlementFlag>();
598
+ let exampleClaim = "";
599
+ let examplePath = "";
600
+ for (const undisclosed of flags.filter((flag) => flag.kind === "undisclosed-file")) {
601
+ const name = basename(undisclosed.file);
602
+ const extension = name.lastIndexOf(".");
603
+ const stem = extension > 0 ? name.slice(0, extension) : name;
604
+ if (stem.length < 3) continue;
605
+ for (const claim of unmatched) {
606
+ if (!claim.file.includes(stem)) continue;
607
+ removed.add(undisclosed);
608
+ removed.add(claim);
609
+ if (exampleClaim === "") {
610
+ exampleClaim = claim.file;
611
+ examplePath = undisclosed.file;
612
+ }
613
+ }
614
+ }
615
+ if (removed.size === 0) return;
616
+
617
+ const kept = flags.filter((flag) => !removed.has(flag));
618
+ const removedClaims = unmatched.filter((flag) => removed.has(flag)).length;
619
+ flags.splice(
620
+ 0,
621
+ flags.length,
622
+ ...kept,
623
+ {
624
+ kind: "report-format-unparsed",
625
+ file: "(report)",
626
+ detail:
627
+ `${removedClaims} claim(s) on the \`changed:\` line could not be parsed as paths yet name the same ` +
628
+ `file(s) the PR touched (e.g. ${exampleClaim} vs ${examplePath}) — read the diff directly`,
629
+ },
630
+ );
562
631
  }
563
632
 
564
633
  function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
@@ -9,27 +9,53 @@
9
9
 
10
10
  import type { ReportingPolicy } from "./types.ts";
11
11
 
12
- /** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
13
- export function localDayKey(at: number, timezone?: string): string {
14
- const parts = new Intl.DateTimeFormat("en-CA", {
15
- timeZone: timezone,
16
- year: "numeric",
17
- month: "2-digit",
18
- day: "2-digit",
19
- }).formatToParts(new Date(at));
20
- const get = (type: "year" | "month" | "day"): string =>
21
- parts.find((p) => p.type === type)?.value ?? "00";
22
- return `${get("year")}-${get("month")}-${get("day")}`;
12
+ const MINUTE_MS = 60_000;
13
+ const NEXT_DIGEST_HORIZON_MS = 3 * 24 * 60 * MINUTE_MS;
14
+
15
+ export type DigestScheduleState =
16
+ | { mode: "disabled" }
17
+ | { mode: "per-tick" }
18
+ | { mode: "due"; timezone: string }
19
+ | { mode: "scheduled"; timezone: string; nextAt?: number };
20
+
21
+ const localMinuteFormatters = new Map<string, Intl.DateTimeFormat>();
22
+ interface CachedDigestSchedule {
23
+ lastDigestDayKey: string | undefined;
24
+ from: number;
25
+ until: number;
26
+ state: Extract<DigestScheduleState, { mode: "scheduled" }>;
27
+ }
28
+
29
+ const schedules = new WeakMap<ReportingPolicy["digest"], CachedDigestSchedule>();
30
+
31
+
32
+ function localDigestMinute(at: number, timezone?: string): { day: string; clock: string } {
33
+ const key = timezone ?? "";
34
+ let formatter = localMinuteFormatters.get(key);
35
+ if (formatter === undefined) {
36
+ formatter = new Intl.DateTimeFormat("en-CA", {
37
+ timeZone: timezone,
38
+ year: "numeric",
39
+ month: "2-digit",
40
+ day: "2-digit",
41
+ hour: "2-digit",
42
+ minute: "2-digit",
43
+ hourCycle: "h23",
44
+ });
45
+ localMinuteFormatters.set(key, formatter);
46
+ }
47
+ const parts = formatter.formatToParts(new Date(at));
48
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
49
+ parts.find((part) => part.type === type)?.value ?? "00";
50
+ return {
51
+ day: `${get("year")}-${get("month")}-${get("day")}`,
52
+ clock: `${get("hour")}:${get("minute")}`,
53
+ };
23
54
  }
24
55
 
25
- /** `HH:MM` wall-clock in the zone, 24h and zero-padded. */
26
- function localClockAt(at: number, timezone?: string): string {
27
- return new Intl.DateTimeFormat("en-GB", {
28
- timeZone: timezone,
29
- hour: "2-digit",
30
- minute: "2-digit",
31
- hourCycle: "h23",
32
- }).format(new Date(at));
56
+ /** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
57
+ export function localDayKey(at: number, timezone?: string): string {
58
+ return localDigestMinute(at, timezone).day;
33
59
  }
34
60
 
35
61
  /**
@@ -51,9 +77,51 @@ export function digestDue(
51
77
  if (cadence === "none") return false;
52
78
  if (cadence === "per-tick") return true;
53
79
  // daily
54
- if (at === undefined) {
55
- return lastDigestDayKey !== localDayKey(now, timezone);
80
+ const local = localDigestMinute(now, timezone);
81
+ if (at === undefined) return lastDigestDayKey !== local.day;
82
+ if (lastDigestDayKey === local.day) return false;
83
+ return local.clock >= at;
84
+ }
85
+
86
+ /** Mechanical status for the next digest opportunity, using the same
87
+ * predicate that gates report submission. Minute scanning deliberately keeps
88
+ * skipped/repeated DST wall-clock times on the runtime's real timeline. */
89
+ export function digestScheduleState(
90
+ policy: Pick<ReportingPolicy, "digest">,
91
+ lastDigestDayKey: string | undefined,
92
+ now: number,
93
+ ): DigestScheduleState {
94
+ if (policy.digest.cadence === "none") return { mode: "disabled" };
95
+ if (policy.digest.cadence === "per-tick") return { mode: "per-tick" };
96
+
97
+ const timezone =
98
+ policy.digest.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
99
+ if (digestDue(policy, lastDigestDayKey, now)) return { mode: "due", timezone };
100
+
101
+ const from = Math.floor(now / MINUTE_MS) * MINUTE_MS;
102
+ const cached = schedules.get(policy.digest);
103
+ if (
104
+ cached !== undefined &&
105
+ cached.lastDigestDayKey === lastDigestDayKey &&
106
+ from >= cached.from &&
107
+ from < cached.until
108
+ ) {
109
+ return cached.state;
56
110
  }
57
- if (lastDigestDayKey === localDayKey(now, timezone)) return false;
58
- return localClockAt(now, timezone) >= at;
59
- }
111
+
112
+ const end = now + NEXT_DIGEST_HORIZON_MS;
113
+ let cursor = from + MINUTE_MS;
114
+ while (cursor <= end && !digestDue(policy, lastDigestDayKey, cursor)) cursor += MINUTE_MS;
115
+ const state: Extract<DigestScheduleState, { mode: "scheduled" }> = {
116
+ mode: "scheduled",
117
+ timezone,
118
+ ...(cursor > end ? {} : { nextAt: cursor }),
119
+ };
120
+ schedules.set(policy.digest, {
121
+ lastDigestDayKey,
122
+ from,
123
+ until: cursor,
124
+ state,
125
+ });
126
+ return state;
127
+ }
package/src/escalate.ts CHANGED
@@ -20,11 +20,12 @@
20
20
  * those strings end up in daemon logs and, on the fallback path, in a public
21
21
  * issue comment.
22
22
  */
23
-
23
+ import { createHash } from "node:crypto";
24
24
  import { readFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { join } from "node:path";
27
27
 
28
+ import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
28
29
  import type { OrchestratorHandle } from "./orchestrator.ts";
29
30
  import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
30
31
 
@@ -137,13 +138,20 @@ export function formatEscalation(e: Escalation, project: string): string {
137
138
  * still escalate, just to an issue comment.
138
139
  */
139
140
  export function createEscalator(
140
- p: ProjectConfig,
141
+ source: ProjectConfig | (() => ProjectConfig),
141
142
  tracker: Tracker,
142
143
  store: Store,
143
144
  orchestrator?: OrchestratorHandle,
145
+ now: () => number = Date.now,
146
+ deliveryAllowed: () => boolean = () => true,
144
147
  ): Escalator {
148
+ const currentProject = typeof source === "function" ? source : (): ProjectConfig => source;
145
149
  return {
146
150
  async escalate(e: Escalation): Promise<void> {
151
+ // The daemon's provider resolves the config reloaded at the latest tick
152
+ // boundary. Capture one snapshot for this delivery, including late
153
+ // settlement callbacks, so a mid-call edit cannot split its policy.
154
+ const p = currentProject();
147
155
  // Stable across daemon restarts: same project, issue, tier and summary is
148
156
  // the same event, however many times the loop rediscovers it.
149
157
  const key = `${p.name}:${e.issue}:${e.tier}:${e.summary}`;
@@ -210,26 +218,45 @@ export function createEscalator(
210
218
  }
211
219
  }
212
220
 
221
+ if (e.tier === 2) {
222
+ const category = e.category ?? "tier2";
223
+ const at = now();
224
+ let disposition: InterruptDisposition;
225
+ if (!deliveryAllowed()) {
226
+ disposition = "availability";
227
+ } else if (e.urgent) {
228
+ // Urgency may bypass category batching when the digest loop itself is
229
+ // broken (#246), but it cannot invent an out-of-hours bypass the
230
+ // operator did not configure (#273).
231
+ const window = p.reporting?.availability;
232
+ disposition =
233
+ window !== undefined &&
234
+ !availabilityOpen(window, at) &&
235
+ !window.bypass.includes(category)
236
+ ? "availability"
237
+ : "interrupt";
238
+ } else {
239
+ disposition = interruptDisposition(p.reporting, category, at);
240
+ }
241
+ if (disposition !== "interrupt") {
242
+ store.addHeldNotice({
243
+ id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
244
+ project: p.name,
245
+ category,
246
+ summary: e.summary,
247
+ detail: text,
248
+ createdAt: at,
249
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
250
+ ...(e.urgent === true ? { urgent: true } : {}),
251
+ });
252
+ store.markNotified(key);
253
+ return;
254
+ }
255
+ }
256
+
213
257
  if (e.tier === 2 && chatId) {
214
258
  const token = readTelegramToken();
215
259
  if (token) {
216
- // The digest can own deferred delivery only while its orchestrator
217
- // loop is alive. An urgent escalation says that loop is the failed
218
- // component, so waiting for its digest would park the only warning
219
- // behind the failure it reports (#246).
220
- const category = e.category ?? "tier2";
221
- const interruptOn = p.reporting?.interruptOn;
222
- if (!e.urgent && interruptOn !== undefined && !interruptOn.includes(category)) {
223
- store.addHeldNotice({
224
- project: p.name,
225
- category,
226
- summary: e.summary,
227
- detail: text,
228
- createdAt: Date.now(),
229
- });
230
- store.markNotified(key);
231
- return;
232
- }
233
260
  // A send failure throws: `markNotified` stays uncalled so the next
234
261
  // poll retries instead of writing the event off as delivered. No
235
262
  // backoff in here — the dispatcher tick *is* the retry, and an
@@ -242,7 +269,7 @@ export function createEscalator(
242
269
  // the tick re-raises it. A *report* has no such source — it exists
243
270
  // once, in the model's head, and nothing regenerates it — which is
244
271
  // why that one needed a ledger and this one does not.
245
- await sendTelegram(token, chatId, text);
272
+ await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
246
273
  store.markNotified(key);
247
274
  return;
248
275
  }
@@ -330,14 +357,52 @@ export function readTelegramToken(): string | undefined {
330
357
  * Telegram's own message id when it returns one, and throws on every *known*
331
358
  * failure — connection refused, HTTP error, `{"ok":false}` — which is what lets
332
359
  * a caller treat a throw as "nobody has this" and a crash as "nobody knows".
360
+ *
361
+ * Optional `topicId` pins the message to a forum topic (`message_thread_id`).
362
+ * A definitive missing-thread reject retries once as a flat chat and warns, so a
363
+ * deleted topic degrades instead of silently losing the page (#318).
333
364
  */
334
365
  export async function sendTelegram(
335
366
  token: string,
336
367
  chatId: string,
337
368
  text: string,
369
+ opts?: { topicId?: number },
370
+ ): Promise<number | undefined> {
371
+ const topicId =
372
+ opts?.topicId !== undefined && Number.isFinite(opts.topicId) && Number.isSafeInteger(opts.topicId)
373
+ ? opts.topicId
374
+ : undefined;
375
+ try {
376
+ return await postTelegramMessage(token, chatId, text, topicId);
377
+ } catch (err) {
378
+ if (
379
+ topicId === undefined ||
380
+ !(err instanceof TelegramSendError) ||
381
+ err.outcome !== "definitive" ||
382
+ !isMissingTelegramThread(err.message)
383
+ ) {
384
+ throw err;
385
+ }
386
+ warn(
387
+ `escalation.telegramTopicId=${topicId} is stale (${err.message}); retrying flat chat`,
388
+ );
389
+ return await postTelegramMessage(token, chatId, text, undefined);
390
+ }
391
+ }
392
+
393
+ /** Telegram's definitive "that forum topic is gone" answers. */
394
+ function isMissingTelegramThread(diagnostic: string): boolean {
395
+ return /message thread not found|topic_id_invalid/i.test(diagnostic);
396
+ }
397
+
398
+ async function postTelegramMessage(
399
+ token: string,
400
+ chatId: string,
401
+ text: string,
402
+ topicId: number | undefined,
338
403
  ): Promise<number | undefined> {
339
404
  const url = `https://api.telegram.org/bot${token}/sendMessage`;
340
- const body = JSON.stringify({
405
+ const payload: Record<string, unknown> = {
341
406
  chat_id: chatId,
342
407
  // ponytail: hard truncation rather than splitting across messages — the
343
408
  // tail of a stack trace is rarely the interesting part. Upgrade path is to
@@ -345,7 +410,9 @@ export async function sendTelegram(
345
410
  text:
346
411
  text.length > TELEGRAM_TEXT_LIMIT ? `${text.slice(0, TELEGRAM_TEXT_LIMIT)}\n[truncated]` : text,
347
412
  disable_web_page_preview: true,
348
- });
413
+ };
414
+ if (topicId !== undefined) payload.message_thread_id = topicId;
415
+ const body = JSON.stringify(payload);
349
416
 
350
417
  let res: Response;
351
418
  try {