omp-conductor 0.15.9 → 0.15.10

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 CHANGED
@@ -1499,8 +1499,9 @@ orchestrator floor now says to name **neither** target or **both**, and the
1499
1499
  dispatcher refuses `chat_id` without `thread_id` for a project that configured
1500
1500
  `escalation.telegramTopicId`. A flat-chat project is unaffected. When a pinned
1501
1501
  topic id has gone stale — the bridge re-claims pane topics across restarts — the
1502
- live claim named for the project is used instead, so a restart does not quietly
1503
- move every page into the main chat (#407).
1502
+ live claim for the project's herdr space is used instead, falling back to a
1503
+ claim titled for the project, so a restart does not quietly move every page into
1504
+ the main chat (#407, #412).
1504
1505
 
1505
1506
  A locally injected tick has no inbound message to inherit a topic from, so a
1506
1507
  bare `telegram_send` there has nothing to preserve. That turn addresses the
@@ -1927,7 +1928,7 @@ Field notes:
1927
1928
  | `migrations` | Optional, per repo: `{ "dir": "backend/alembic/versions" }`. Names the repo-relative directory of an Alembic-style ordered migration chain (`revision` / `down_revision` in `*.py`). When set, `conductor_pr_merge` **refuses** a merge that would corrupt the chain at the base tip: reusing a revision id another file already declares, deleting a published migration, or a merge that would leave the combined graph with more than one head (so a stale parent is refused, and a fork-repair merge migration that unifies the heads passes). Absent means the repo opts out of the chain check entirely. Repo-relative only: a leading `/` or `..` is an error. |
1928
1929
  | `caps` | Per-project overrides; omit it or pin only the fields you want to change. |
1929
1930
  | `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
1930
- | `escalation.telegramTopicId` | Optional forum topic for everything conductor sends: tier-2 pages, reports, digests, arm challenges, `omp-conductor message`. Setup offers the topics omp-telegram has claimed. The bridge re-claims a pane's topic across restarts — including the restarts `upgrade` and `restart` perform — so a pinned id that is no longer claimed is replaced at send time by the live claim whose name matches the project, logged without ids (#407). A pin that is still claimed always wins, so a deliberately separate topic is never hijacked. Absent keeps flat-chat behaviour. |
1931
+ | `escalation.telegramTopicId` | Optional forum topic for everything conductor sends: tier-2 pages, reports, digests, arm challenges, `omp-conductor message`. Setup offers the topics omp-telegram has claimed, naming each one's herdr space. The bridge re-claims a pane's topic across restarts — including the restarts `upgrade` and `restart` perform — so a pinned id that is no longer claimed is replaced at send time by the live claim whose **herdr space** is this project, falling back to one titled for the project, logged without ids (#407, #412). The space is read first because the bridge titles a topic `ownAgentName ?? basename(cwd)`, and a multi-project host whose panes sit under one state directory gives every claim the same title. An identity two claims share is treated as no match at all rather than a guess. A pin that is still claimed always wins, so a deliberately separate topic is never hijacked. Absent keeps flat-chat behaviour. |
1931
1932
  | `escalation.orchestrator` | Optional; `"embedded"` (default) or `"external"`. `external` means an orchestrator session already runs elsewhere: the daemon starts none, and tier-1 escalations post as issue comments for that session to drain. Any other value is an error. |
1932
1933
  | `authority` | Optional; `{ "merge": …, "release": … }`, each `"human"` (default) or `"orchestrator"`. It grants nothing to the daemon — it words the orchestrator's standing orders and the Releases paragraph of the rendered brief, so the config and the prompt cannot disagree about who holds the merge button. Unknown keys and any other value are errors, never folded to the default. |
1933
1934
  | `releasePolicy` | Optional; a per-shape map whose values are `"human"` (default) or `"orchestrator"`. Shapes are `version-bump-pr`, `git-tag`, `git-push-tags`, `package-publish`, `github-release`, and `deploy`. The legacy `"none"` denies every shape; legacy `"operator-brief"` grants the artifact-producing shapes, including reviewed version preparation, but keeps deploy human-owned. The in-session tripwire blocks recognised raw release/deploy calls before execution. Every rejection is written to `release-policy-blocks.jsonl`; the heartbeat carries that day's count into the daily digest. This is the mechanical gate; `authority.release` still says who owns the decision. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.9",
3
+ "version": "0.15.10",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/escalate.ts CHANGED
@@ -350,6 +350,15 @@ export function readTelegramToken(): string | undefined {
350
350
  return undefined;
351
351
  }
352
352
 
353
+ /** One live omp-telegram topic claim, as much of it as conductor reads. */
354
+ export interface ClaimedTopic {
355
+ threadId: number;
356
+ /** The bridge's own title for the claim. */
357
+ name: string;
358
+ /** The herdr space the claiming pane sits in, when the bridge captured one. */
359
+ workspaceLabel?: string;
360
+ }
361
+
353
362
  /**
354
363
  * The forum topic a project's Telegram actually lives in *now*.
355
364
  *
@@ -362,12 +371,19 @@ export function readTelegramToken(): string | undefined {
362
371
  *
363
372
  * The operator's pin still wins whenever it is live. Only a pin that is
364
373
  * *provably* absent from the current claims is replaced, and only by the claim
365
- * whose name is this project's — so a deliberately separate alerts topic is
366
- * never hijacked by the pane's own thread. Unreadable bridge state changes
374
+ * this project can be identified with — so a deliberately separate alerts topic
375
+ * is never hijacked by the pane's own thread. Unreadable bridge state changes
367
376
  * nothing, and #318's stale-topic retry remains the last line of defence.
368
377
  *
369
- * The substitution is logged, naming the project and the claim's name. Never an
370
- * id: a log line is a place these leak from.
378
+ * Identity is read from the herdr space first, and only then from the claim's
379
+ * title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
380
+ * halves of that fall down on a real multi-project host: the agent-name lookup
381
+ * is best-effort, and a fleet whose panes live under one state directory gives
382
+ * every pane the same basename. `workspaceLabel` is the herdr space, which is
383
+ * one-to-one with the project, so it is the identity that survives a re-claim.
384
+ *
385
+ * The substitution is logged, naming the project and which identity answered.
386
+ * Never an id: a log line is a place these leak from.
371
387
  */
372
388
  export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
373
389
  const pinned = project.escalation.telegramTopicId;
@@ -375,24 +391,42 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
375
391
  const claims = claimedTelegramTopics();
376
392
  if (claims.length === 0) return pinned;
377
393
  if (claims.some((claim) => claim.threadId === pinned)) return pinned;
378
- const named = claims.find((claim) => claim.name === project.name);
379
- if (named === undefined) return pinned;
394
+ const bySpace = soleClaim(claims, (claim) => claim.workspaceLabel === project.name);
395
+ const match = bySpace ?? soleClaim(claims, (claim) => claim.name === project.name);
396
+ if (match === undefined) return pinned;
380
397
  warn(
381
398
  `escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
382
- `following omp-telegram's current "${named.name}" claim instead`,
399
+ `following omp-telegram's current "${project.name}" ` +
400
+ `${bySpace === undefined ? "claim" : "herdr space claim"} instead`,
383
401
  );
384
- return named.threadId;
402
+ return match.threadId;
403
+ }
404
+
405
+ /**
406
+ * The one claim answering to `predicate`, or nothing when none or several do.
407
+ *
408
+ * Ambiguity is not a coin toss: two claims wearing the same identity means the
409
+ * bridge cannot say which pane belongs to this project, and paging the wrong
410
+ * project's topic is worse than the flat-chat degrade #318 already handles.
411
+ */
412
+ function soleClaim(
413
+ claims: readonly ClaimedTopic[],
414
+ predicate: (claim: ClaimedTopic) => boolean,
415
+ ): ClaimedTopic | undefined {
416
+ const hits = claims.filter(predicate);
417
+ return hits.length === 1 ? hits[0] : undefined;
385
418
  }
386
419
 
387
420
  /**
388
- * omp-telegram's live topic claims: `{ threads: { "<threadId>": { name } } }`.
389
- * Borrowed exactly as the token is, and tolerant of every shape it might not
390
- * be a bridge that has never run a forum pane has no file at all.
421
+ * omp-telegram's live topic claims, from
422
+ * `{ threads: { "<threadId>": { name, workspaceLabel } } }`. Borrowed exactly as
423
+ * the token is, and tolerant of every shape it might not be a bridge that has
424
+ * never run a forum pane has no file at all.
391
425
  *
392
426
  * `stateDir` is passed by the setup wizard, which has already probed for the
393
427
  * bridge; send-time callers let it resolve the same way the token does.
394
428
  */
395
- export function claimedTelegramTopics(stateDir?: string): Array<{ threadId: number; name: string }> {
429
+ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
396
430
  const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
397
431
  const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
398
432
  let raw: unknown;
@@ -404,16 +438,23 @@ export function claimedTelegramTopics(stateDir?: string): Array<{ threadId: numb
404
438
  if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) return [];
405
439
  const threads = raw.threads;
406
440
  if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
407
- const out: Array<{ threadId: number; name: string }> = [];
441
+ const out: ClaimedTopic[] = [];
408
442
  for (const [id, entry] of Object.entries(threads)) {
409
443
  const threadId = Number(id);
410
444
  if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
411
445
  let name = id;
412
- if (typeof entry === "object" && entry !== null && "name" in entry) {
413
- const candidate = entry.name;
414
- if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
446
+ let workspaceLabel: string | undefined;
447
+ if (typeof entry === "object" && entry !== null) {
448
+ if ("name" in entry) {
449
+ const candidate = entry.name;
450
+ if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
451
+ }
452
+ if ("workspaceLabel" in entry) {
453
+ const candidate = entry.workspaceLabel;
454
+ if (typeof candidate === "string" && candidate.trim() !== "") workspaceLabel = candidate.trim();
455
+ }
415
456
  }
416
- out.push({ threadId, name });
457
+ out.push(workspaceLabel === undefined ? { threadId, name } : { threadId, name, workspaceLabel });
417
458
  }
418
459
  return out;
419
460
  }
@@ -1216,7 +1216,12 @@ async function askTelegramTopicId(
1216
1216
  const options = [
1217
1217
  ...claimed.map((t) => ({
1218
1218
  label: `${t.name} — ${t.threadId}`,
1219
- description: `message_thread_id ${t.threadId}`,
1219
+ // Two panes under one state directory claim the same title, so the
1220
+ // herdr space is the only thing that tells the operator which is which.
1221
+ description:
1222
+ t.workspaceLabel === undefined || t.workspaceLabel === t.name
1223
+ ? `message_thread_id ${t.threadId}`
1224
+ : `herdr space ${t.workspaceLabel} · message_thread_id ${t.threadId}`,
1220
1225
  })),
1221
1226
  { label: manual, description: "type a numeric forum topic id" },
1222
1227
  { label: none, description: "send to the chat root, not a topic" },