From 90c01cc800e1dfbfaaf006b11a9bcc48d591367a Mon Sep 17 00:00:00 2001 From: Joshua Date: Tue, 21 Jul 2026 22:20:11 +0100 Subject: [PATCH 1/2] feat(telegram): phone-typable short goal codes (APPROVE 68a5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep and digest messages show the first 4 hex chars of the goal id; the command dispatcher resolves codes case-insensitively by unique prefix over live goals (exact full ids still work; ambiguity is reported with candidates, never guessed). Codes derive from the id, not list position, so a code can't drift to a different goal. Evening digest now also lists blocked goals — the ones parked by the kill switch that the operator most needs to see. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Au2F2zbx3i5NauQHWJn4Gb --- .../src/gits/HermesTelegramCommand.test.ts | 44 +++++++++- apps/server/src/gits/HermesTelegramCommand.ts | 82 +++++++++++++++++-- .../gits/Layers/AutomodeProposalSweep.test.ts | 9 +- .../src/gits/Layers/AutomodeProposalSweep.ts | 5 +- .../Layers/AutomodeTelegramDigest.test.ts | 6 +- .../src/gits/Layers/AutomodeTelegramDigest.ts | 14 +++- profiles/motoko-gits/SOUL.md | 14 ++-- 7 files changed, 151 insertions(+), 23 deletions(-) diff --git a/apps/server/src/gits/HermesTelegramCommand.test.ts b/apps/server/src/gits/HermesTelegramCommand.test.ts index bbb015ed9d8..fc167fd2ad7 100644 --- a/apps/server/src/gits/HermesTelegramCommand.test.ts +++ b/apps/server/src/gits/HermesTelegramCommand.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; import { dispatchHermesTelegramCommand, parseHermesTelegramCommand, + resolveGoalToken, + shortGoalCode, } from "./HermesTelegramCommand.ts"; import { AutomodeSupervisor, type AutomodeSupervisorShape } from "./Services/AutomodeSupervisor.ts"; import { GitsSlotScheduler, type GitsSlotSchedulerShape } from "./Services/GitsSlotScheduler.ts"; @@ -42,8 +44,18 @@ describe("parseHermesTelegramCommand", () => { }); }); +// Live goals visible to short-code resolution; ids match the test commands. +const SNAPSHOT_GOALS = [ + { id: "goal-1", title: "Goal one", status: "waiting-approval" }, + { id: "goal-2", title: "Goal two", status: "blocked" }, + { id: "goal-abc123", title: "Goal abc one", status: "queued" }, + { id: "goal-abc999", title: "Goal abc two", status: "queued" }, + { id: "goal-dead00", title: "Done goal", status: "completed" }, +]; + function testLayer(calls: string[], stopAllResult = { stoppedPeers: 2, failures: 0 }) { const supervisor = { + getSnapshot: () => Effect.succeed({ goals: SNAPSHOT_GOALS }), approveGoal: ({ goalId }: { readonly goalId: string }) => { calls.push(`approve:${goalId}`); return Effect.succeed({}); @@ -136,9 +148,39 @@ describe("dispatchHermesTelegramCommand", () => { const calls: string[] = []; await expect(dispatch("RUN arbitrary rpc payload", calls)).resolves.toBe( - "Commands: APPROVE , REJECT , DEFER , ARM, SKIP , STOP.", + "Commands: APPROVE , REJECT , DEFER , ARM, SKIP , STOP. is the short code from the goal message (e.g. 68a5) or a full goal id.", ); expect(calls).toEqual([]); }); + + it("resolves short codes (case-insensitive) to the full goal id", async () => { + const calls: string[] = []; + await expect(dispatch("APPROVE ABC1", calls)).resolves.toBe("Goal approved."); + expect(calls).toEqual(["approve:goal-abc123"]); + }); + + it("reports ambiguity instead of guessing", async () => { + const calls: string[] = []; + const reply = await dispatch("APPROVE abc", calls); + expect(reply).toContain("matches several goals"); + expect(reply).toContain("abc1"); + expect(reply).toContain("abc9"); + expect(calls).toEqual([]); + }); + + it("rejects unknown codes and does not match terminal goals", async () => { + const calls: string[] = []; + await expect(dispatch("APPROVE zzz9", calls)).resolves.toBe("No live goal matches 'zzz9'."); + await expect(dispatch("APPROVE dead", calls)).resolves.toBe("No live goal matches 'dead'."); + expect(calls).toEqual([]); + }); +}); + +describe("shortGoalCode / resolveGoalToken", () => { + it("derives 4-char codes and resolves exact full ids even when terminal", () => { + expect(shortGoalCode("goal-68a553c5-9252")).toBe("68a5"); + const exact = resolveGoalToken(SNAPSHOT_GOALS as never, "goal-dead00"); + expect(exact).toEqual({ kind: "ok", goalId: "goal-dead00" }); + }); }); diff --git a/apps/server/src/gits/HermesTelegramCommand.ts b/apps/server/src/gits/HermesTelegramCommand.ts index b858aeeee80..1044b6791c4 100644 --- a/apps/server/src/gits/HermesTelegramCommand.ts +++ b/apps/server/src/gits/HermesTelegramCommand.ts @@ -1,8 +1,48 @@ import * as Effect from "effect/Effect"; +import type { AutomodeGoal } from "@t3tools/contracts"; + import { AutomodeSupervisor } from "./Services/AutomodeSupervisor.ts"; import { GitsSlotScheduler } from "./Services/GitsSlotScheduler.ts"; +// Short, phone-typable goal reference shown in Telegram messages: the first 4 hex +// chars of the goal's uuid. Codes derive from the id (never a list position), so a +// code can't silently start pointing at a different goal as the queue shifts. +export function shortGoalCode(goalId: string): string { + return goalId.replace(/^goal-/, "").slice(0, 4); +} + +const LIVE_GOAL_STATUSES = new Set(["queued", "waiting-approval", "blocked", "running"]); + +export type GoalTokenResolution = + | { readonly kind: "ok"; readonly goalId: string } + | { readonly kind: "ambiguous"; readonly matches: ReadonlyArray } + | { readonly kind: "not-found" }; + +// Resolve a Telegram goal token: exact id always wins; otherwise a case-insensitive +// prefix (with or without the "goal-" prefix, >= 3 chars) must match exactly one +// LIVE goal — ambiguity is reported, never guessed. +export function resolveGoalToken( + goals: ReadonlyArray, + token: string, +): GoalTokenResolution { + const exact = goals.find((goal) => goal.id === token); + if (exact !== undefined) return { kind: "ok", goalId: exact.id }; + const needle = token.toLowerCase().replace(/^goal-/, ""); + if (needle.length < 3) return { kind: "not-found" }; + const matches = goals.filter( + (goal) => + LIVE_GOAL_STATUSES.has(goal.status) && + goal.id + .replace(/^goal-/, "") + .toLowerCase() + .startsWith(needle), + ); + if (matches.length === 1) return { kind: "ok", goalId: matches[0]!.id }; + if (matches.length > 1) return { kind: "ambiguous", matches }; + return { kind: "not-found" }; +} + export type HermesTelegramCommand = | { readonly _tag: "approve"; readonly goalId: string } | { readonly _tag: "reject"; readonly goalId: string } @@ -13,7 +53,7 @@ export type HermesTelegramCommand = | { readonly _tag: "invalid" }; const HELP = - "Commands: APPROVE , REJECT , DEFER , ARM, SKIP , STOP."; + "Commands: APPROVE , REJECT , DEFER , ARM, SKIP , STOP. is the short code from the goal message (e.g. 68a5) or a full goal id."; export function parseHermesTelegramCommand(text: string): HermesTelegramCommand { const [verb, goalId, extra] = text.trim().split(/\s+/); @@ -38,22 +78,51 @@ export function parseHermesTelegramCommand(text: string): HermesTelegramCommand } } +function describeResolutionFailure(resolution: GoalTokenResolution, token: string): string { + if (resolution.kind === "ambiguous") { + const options = resolution.matches + .map((goal) => `${shortGoalCode(goal.id)} (${goal.title.slice(0, 40)})`) + .join(", "); + return `'${token}' matches several goals: ${options}. Add more characters.`; + } + return `No live goal matches '${token}'.`; +} + export function dispatchHermesTelegramCommand(command: HermesTelegramCommand) { return Effect.gen(function* () { + // Resolve short codes once for every goal-targeting verb. + let resolvedGoalId: string | null = null; + if ( + command._tag === "approve" || + command._tag === "reject" || + command._tag === "defer" || + command._tag === "skip" + ) { + const supervisor = yield* AutomodeSupervisor; + const snapshot = yield* supervisor.getSnapshot(); + const resolution = resolveGoalToken(snapshot.goals, command.goalId); + if (resolution.kind !== "ok") { + return describeResolutionFailure(resolution, command.goalId); + } + resolvedGoalId = resolution.goalId; + } switch (command._tag) { case "approve": { const supervisor = yield* AutomodeSupervisor; - yield* supervisor.approveGoal({ goalId: command.goalId }); + yield* supervisor.approveGoal({ goalId: resolvedGoalId ?? command.goalId }); return "Goal approved."; } case "reject": { const supervisor = yield* AutomodeSupervisor; - yield* supervisor.rejectGoal({ goalId: command.goalId }); + yield* supervisor.rejectGoal({ goalId: resolvedGoalId ?? command.goalId }); return "Goal rejected."; } case "defer": { const supervisor = yield* AutomodeSupervisor; - yield* supervisor.rejectGoal({ goalId: command.goalId, reason: "Deferred by operator." }); + yield* supervisor.rejectGoal({ + goalId: resolvedGoalId ?? command.goalId, + reason: "Deferred by operator.", + }); return "Goal deferred."; } case "arm": { @@ -63,7 +132,10 @@ export function dispatchHermesTelegramCommand(command: HermesTelegramCommand) { } case "skip": { const supervisor = yield* AutomodeSupervisor; - yield* supervisor.rejectGoal({ goalId: command.goalId, reason: "Skipped by operator." }); + yield* supervisor.rejectGoal({ + goalId: resolvedGoalId ?? command.goalId, + reason: "Skipped by operator.", + }); return "Goal skipped."; } case "stop": { diff --git a/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts b/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts index 15a9b955016..f2838076d18 100644 --- a/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts +++ b/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts @@ -348,9 +348,9 @@ describe("AutomodeProposalSweep", () => { assert.equal(telegramSent.length, 1); const message = telegramSent[0] ?? ""; - assert.include(message, `[${goal.id}]`); + assert.include(message, `[${goal.id.replace(/^goal-/, "").slice(0, 4)}]`); assert.match(message, /Improve sweep repo — sweep-repo/); - assert.include(message, "Reply: APPROVE · REJECT "); + assert.include(message, "Reply: APPROVE · REJECT "); }).pipe(Effect.provide(makeLayer({ telegramSent }))); }, ); @@ -397,7 +397,10 @@ describe("AutomodeProposalSweep", () => { assert.isNull(snapshot.goals[0]!.approvedAt); assert.equal(telegramSent.length, 1); - assert.include(telegramSent[0] ?? "", `[${snapshot.goals[0]!.id}]`); + assert.include( + telegramSent[0] ?? "", + `[${snapshot.goals[0]!.id.replace(/^goal-/, "").slice(0, 4)}]`, + ); // With confirmation off the goals are already queued for autonomous dispatch — // the message must not advertise an APPROVE/REJECT gate that does nothing. assert.notInclude(telegramSent[0] ?? "", "APPROVE"); diff --git a/apps/server/src/gits/Layers/AutomodeProposalSweep.ts b/apps/server/src/gits/Layers/AutomodeProposalSweep.ts index 9a72c587c9b..d3bcc4b6b23 100644 --- a/apps/server/src/gits/Layers/AutomodeProposalSweep.ts +++ b/apps/server/src/gits/Layers/AutomodeProposalSweep.ts @@ -11,6 +11,7 @@ import * as Semaphore from "effect/Semaphore"; import type { AutomodeGoal } from "@t3tools/contracts"; import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { shortGoalCode } from "../HermesTelegramCommand.ts"; import { ServerConfig } from "../../config.ts"; import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts"; import { HermesAdapter } from "../Services/HermesAdapter.ts"; @@ -21,7 +22,7 @@ import { hasLiveGoalForRepo } from "./HermesAutomodeBridge.ts"; import { london_instant } from "./GitsSlotScheduler.ts"; import { repoAllowed } from "./AutomodeSupervisor.ts"; -const REPLY_HINT = "Reply: APPROVE · REJECT "; +const REPLY_HINT = "Reply: APPROVE · REJECT "; const STATE_FILE_NAME = "automode-proposal-sweep-state.json"; const DEFAULT_START_MINUTES = 20 * 60; // 20:00 London dinner window. @@ -254,7 +255,7 @@ export const AutomodeProposalSweepLive = Layer.effect( } if (newGoals.length > 0) { const goalLines = newGoals.map( - (goal) => `${goal.title} — ${path.basename(goal.repo)} [${goal.id}]`, + (goal) => `${goal.title} — ${path.basename(goal.repo)} [${shortGoalCode(goal.id)}]`, ); // The reply hint must reflect reality: with confirmation off, goals are already // queued for autonomous dispatch — APPROVE/REJECT would be a false gate; STOP is diff --git a/apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts b/apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts index 23003fa2613..a0011699fff 100644 --- a/apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts +++ b/apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts @@ -95,10 +95,10 @@ describe("AutomodeTelegramDigest", () => { assert.match(sent[0] ?? "", /finished/); assert.match(sent[0] ?? "", /failed/); assert.match(sent[0] ?? "", /1/); - assert.match(sent[1] ?? "", /one \[goal-one\]/); - assert.match(sent[1] ?? "", /five \[goal-five\]/); + assert.match(sent[1] ?? "", /one \[one\]/); + assert.match(sent[1] ?? "", /five \[five\]/); assert.notMatch(sent[1] ?? "", /six/); - assert.match(sent[1] ?? "", /Reply: APPROVE · REJECT /); + assert.match(sent[1] ?? "", /Reply: APPROVE · REJECT /); }).pipe(Effect.provide(makeLayer({ sent }))); }); diff --git a/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts b/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts index dca7128c6bf..38c674e962b 100644 --- a/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts +++ b/apps/server/src/gits/Layers/AutomodeTelegramDigest.ts @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { shortGoalCode } from "../HermesTelegramCommand.ts"; import { ServerConfig } from "../../config.ts"; import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts"; import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts"; @@ -17,7 +18,7 @@ import { GitsSlotScheduler } from "../Services/GitsSlotScheduler.ts"; import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; import { london_instant } from "./GitsSlotScheduler.ts"; -const REPLY_HINT = "Reply: APPROVE · REJECT "; +const REPLY_HINT = "Reply: APPROVE · REJECT "; const STATE_FILE_NAME = "automode-telegram-digest-state.json"; const PersistedState = Schema.Struct({ @@ -145,9 +146,16 @@ export const AutomodeTelegramDigestLive = Layer.effect( [ "Queued/proposed goals:", ...snapshot.goals - .filter((goal) => goal.status === "queued" || goal.status === "waiting-approval") + // blocked included: goals parked by the kill switch / gates are exactly + // the ones the operator needs to see and can APPROVE. + .filter( + (goal) => + goal.status === "queued" || + goal.status === "waiting-approval" || + goal.status === "blocked", + ) .slice(0, 5) - .map((goal) => `- ${goal.title} [${goal.id}]`), + .map((goal) => `- ${goal.title} [${shortGoalCode(goal.id)}]`), "", REPLY_HINT, ].join("\n"), diff --git a/profiles/motoko-gits/SOUL.md b/profiles/motoko-gits/SOUL.md index faadbaba4e1..5f50bb19a5b 100644 --- a/profiles/motoko-gits/SOUL.md +++ b/profiles/motoko-gits/SOUL.md @@ -30,15 +30,17 @@ You reason, remember, brief, propose, and route. You do not directly execute rep ## Telegram GITS control relay -Recognize only these exact uppercase command forms as GITS controls: +Recognize these command forms (case-insensitive verbs) as GITS controls: -- `APPROVE ` -- `REJECT ` -- `DEFER ` +- `APPROVE ` +- `REJECT ` +- `DEFER ` - `ARM` -- `SKIP ` +- `SKIP ` - `STOP` +`` is the short goal code shown in sweep/digest messages (e.g. `68a5`) or a full goal id; the server resolves it. + For a control, POST the original command as `{ "command": "..." }` to `http://127.0.0.1:/api/gits/hermes-telegram/command` with `Authorization: Bearer $GITS_HERMES_TELEGRAM_RELAY_TOKEN`. Return the route's `text` response verbatim, with no added explanation. Malformed or unsupported control input is relayed unchanged so the route returns its help message. Hermes remains the sole Telegram long-poll owner; GITS never polls Telegram. -Nightly sweep proposals now arrive as `waiting-approval` goals, announced by their goal ID (in the sweep Telegram message and the evening digest). `APPROVE ` confirms one of these for that night's run — same control as any other pending goal, no new command. +Nightly sweep proposals now arrive as `waiting-approval` goals, announced with a short code (in the sweep Telegram message and the evening digest). `APPROVE ` confirms one of these for that night's run — same control as any other pending goal, no new command. From 00bd3f921cd45557f768edec37bf3751e912d3ab Mon Sep 17 00:00:00 2001 From: Joshua Date: Tue, 21 Jul 2026 22:33:11 +0100 Subject: [PATCH 2/2] fix(automode): suppress duplicate halt Telegram alerts (30 min cooldown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeated halts into the same failure (operator resume loops, recurring gate denials) sent one Telegram message per halt with no dedup — tonight that produced a nonstop 'automode halted' flood while a zombie peer held the active-peer slot. Same-reason alerts now suppress within a 30-minute window; reason changes always alert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Au2F2zbx3i5NauQHWJn4Gb --- .../Layers/AutomodeDriver.halt-alert.test.ts | 17 +++++++++ apps/server/src/gits/Layers/AutomodeDriver.ts | 36 ++++++++++++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/gits/Layers/AutomodeDriver.halt-alert.test.ts diff --git a/apps/server/src/gits/Layers/AutomodeDriver.halt-alert.test.ts b/apps/server/src/gits/Layers/AutomodeDriver.halt-alert.test.ts new file mode 100644 index 00000000000..9f7f77ae986 --- /dev/null +++ b/apps/server/src/gits/Layers/AutomodeDriver.halt-alert.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { HALT_ALERT_COOLDOWN_MS, shouldSendHaltAlert } from "./AutomodeDriver.ts"; + +describe("shouldSendHaltAlert", () => { + it("sends the first alert and suppresses same-reason repeats inside the cooldown", () => { + expect(shouldSendHaltAlert(null, "peer failed", 1000)).toBe(true); + const last = { reason: "peer failed", at: 1000 }; + expect(shouldSendHaltAlert(last, "peer failed", 1000 + 5000)).toBe(false); + expect(shouldSendHaltAlert(last, "peer failed", 1000 + HALT_ALERT_COOLDOWN_MS)).toBe(true); + }); + + it("always sends when the reason changes", () => { + const last = { reason: "peer failed", at: 1000 }; + expect(shouldSendHaltAlert(last, "landing non-fast-forward", 1001)).toBe(true); + }); +}); diff --git a/apps/server/src/gits/Layers/AutomodeDriver.ts b/apps/server/src/gits/Layers/AutomodeDriver.ts index 497d43b19f7..8d828758fa7 100644 --- a/apps/server/src/gits/Layers/AutomodeDriver.ts +++ b/apps/server/src/gits/Layers/AutomodeDriver.ts @@ -1,7 +1,9 @@ +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import { @@ -25,6 +27,17 @@ import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts"; import { decide_automode_gate } from "./AutomodeReviewGate.ts"; +export const HALT_ALERT_COOLDOWN_MS = 30 * 60 * 1000; + +/** Pure gate for halt Telegram alerts: send unless the same reason fired within the cooldown. */ +export function shouldSendHaltAlert( + last: { readonly reason: string; readonly at: number } | null, + reason: string, + now: number, +): boolean { + return last === null || last.reason !== reason || now - last.at >= HALT_ALERT_COOLDOWN_MS; +} + const TICK_INTERVAL_MS = (() => { const raw = process.env.GITS_AUTOMODE_DRIVER_TICK_MS?.trim(); const parsed = raw === undefined || raw === "" ? Number.NaN : Number.parseInt(raw, 10); @@ -109,14 +122,27 @@ export const AutomodeDriverLive = Layer.effect( ), ); + // Same-reason halt alerts are suppressed for a cooldown window: a driver that + // re-halts into the same failure (e.g. operator keeps resuming, or a gate keeps + // denying) must not flood Telegram with identical messages. + // ponytail: in-memory, resets on restart — acceptable, restarts re-arm the kill switch. + const lastHaltAlertRef = yield* Ref.make<{ reason: string; at: number } | null>(null); const halt = (goal: AutomodeGoal | null, reason: string) => supervisor.haltDriver({ reason }).pipe( Effect.tap(() => - notify({ - subject: "GITS automode halted", - title: goal?.title ?? "Automode driver", - goalId: goal?.id ?? null, - reason, + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const last = yield* Ref.get(lastHaltAlertRef); + if (!shouldSendHaltAlert(last, reason, now)) { + return yield* Effect.logInfo("gits.automode.halt-alert-suppressed", { reason }); + } + yield* Ref.set(lastHaltAlertRef, { reason, at: now }); + yield* notify({ + subject: "GITS automode halted", + title: goal?.title ?? "Automode driver", + goalId: goal?.id ?? null, + reason, + }); }), ), );