diff --git a/src/resources/extensions/gsd/auto/loop-deps.ts b/src/resources/extensions/gsd/auto/loop-deps.ts index 565dde5a3..a7678d85f 100644 --- a/src/resources/extensions/gsd/auto/loop-deps.ts +++ b/src/resources/extensions/gsd/auto/loop-deps.ts @@ -93,6 +93,7 @@ export interface LoopDeps { body: string, kind: string, category: string, + projectName?: string, ) => void; setActiveMilestoneId: (basePath: string, mid: string) => void; pruneQueueOrder: (basePath: string, pendingIds: string[]) => void; diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 26685796d..620fe6809 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -26,7 +26,7 @@ import { runUnit } from "./run-unit.js"; import { debugLog } from "../debug-logger.js"; import { PROJECT_FILES } from "../detection.js"; import { MergeConflictError } from "../git-service.js"; -import { join } from "node:path"; +import { join, basename } from "node:path"; import { existsSync, cpSync } from "node:fs"; import { logWarning, logError } from "../workflow-logger.js"; import { gsdRoot } from "../paths.js"; @@ -230,6 +230,7 @@ export async function runPreDispatch( `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone", + basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent( prefs, @@ -388,6 +389,7 @@ export async function runPreDispatch( "All milestones complete!", "success", "milestone", + basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent( prefs, @@ -411,7 +413,7 @@ export async function runPreDispatch( const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; await deps.stopAuto(ctx, pi, blockerMsg); ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); - deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention", basename(s.originalBasePath || s.basePath)); deps.logCmuxEvent(prefs, blockerMsg, "error"); } else { const ids = incomplete.map((m: { id: string }) => m.id).join(", "); @@ -492,6 +494,7 @@ export async function runPreDispatch( `Milestone ${mid} complete!`, "success", "milestone", + basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent( prefs, @@ -509,7 +512,7 @@ export async function runPreDispatch( const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; await closeoutAndStop(ctx, pi, s, deps, blockerMsg); ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); - deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention", basename(s.originalBasePath || s.basePath)); deps.logCmuxEvent(prefs, blockerMsg, "error"); debugLog("autoLoop", { phase: "exit", reason: "blocked" }); deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "blocked", blockers: state.blockers } }); @@ -755,7 +758,7 @@ export async function runGuards( // 100% — special enforcement logic (halt/pause/warn) const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`; if (budgetEnforcementAction === "halt") { - deps.sendDesktopNotification("GSD", msg, "error", "budget"); + deps.sendDesktopNotification("GSD", msg, "error", "budget", basename(s.originalBasePath || s.basePath)); await deps.stopAuto(ctx, pi, "Budget ceiling reached"); debugLog("autoLoop", { phase: "exit", reason: "budget-halt" }); return { action: "break", reason: "budget-halt" }; @@ -765,14 +768,14 @@ export async function runGuards( `${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning", ); - deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + deps.sendDesktopNotification("GSD", msg, "warning", "budget", basename(s.originalBasePath || s.basePath)); deps.logCmuxEvent(prefs, msg, "warning"); await deps.pauseAuto(ctx, pi); debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); return { action: "break", reason: "budget-pause" }; } ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); - deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + deps.sendDesktopNotification("GSD", msg, "warning", "budget", basename(s.originalBasePath || s.basePath)); deps.logCmuxEvent(prefs, msg, "warning"); } else if (threshold.pct < 100) { // Sub-100% — simple notification @@ -783,6 +786,7 @@ export async function runGuards( msg, threshold.notifyLevel, "budget", + basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel); } @@ -812,6 +816,7 @@ export async function runGuards( `Context ${contextUsage.percent}% — paused`, "warning", "attention", + basename(s.originalBasePath || s.basePath), ); await deps.pauseAuto(ctx, pi); debugLog("autoLoop", { phase: "exit", reason: "context-window" }); diff --git a/src/resources/extensions/gsd/notifications.ts b/src/resources/extensions/gsd/notifications.ts index 4a45eae94..0efd0d4c3 100644 --- a/src/resources/extensions/gsd/notifications.ts +++ b/src/resources/extensions/gsd/notifications.ts @@ -23,7 +23,13 @@ export function sendDesktopNotification( message: string, level: NotifyLevel = "info", kind: NotificationKind = "complete", + projectName?: string, ): void { + // When a projectName is provided and the title is the default "GSD", + // replace it with a project-qualified title for multi-project clarity. + if (projectName && title === "GSD") { + title = formatNotificationTitle(projectName); + } const loaded = loadEffectiveGSDPreferences()?.preferences; if (!shouldSendDesktopNotification(kind, loaded?.notifications)) return; @@ -64,6 +70,16 @@ export function shouldSendDesktopNotification( } } +/** + * Format a notification title that includes the project name for context. + * Returns "GSD — projectName" when a project name is available, otherwise "GSD". + */ +export function formatNotificationTitle(projectName?: string): string { + const trimmed = projectName?.trim(); + if (trimmed) return `GSD — ${trimmed}`; + return "GSD"; +} + export function buildDesktopNotificationCommand( platform: NodeJS.Platform, title: string, diff --git a/src/resources/extensions/gsd/tests/notifications.test.ts b/src/resources/extensions/gsd/tests/notifications.test.ts index b833c667b..0331f5956 100644 --- a/src/resources/extensions/gsd/tests/notifications.test.ts +++ b/src/resources/extensions/gsd/tests/notifications.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { buildDesktopNotificationCommand, shouldSendDesktopNotification, + formatNotificationTitle, } from "../notifications.js"; import type { NotificationPreferences } from "../types.js"; @@ -87,3 +88,47 @@ test("buildDesktopNotificationCommand preserves literal shell characters on linu test("buildDesktopNotificationCommand skips unsupported platforms", () => { assert.equal(buildDesktopNotificationCommand("win32", "Title", "Message"), null); }); + +// ─── formatNotificationTitle — project context in notifications (#2708) ────── + +test("formatNotificationTitle returns 'GSD' when no project name is given", () => { + assert.equal(formatNotificationTitle(), "GSD"); + assert.equal(formatNotificationTitle(undefined), "GSD"); + assert.equal(formatNotificationTitle(""), "GSD"); +}); + +test("formatNotificationTitle includes project name when provided", () => { + assert.equal(formatNotificationTitle("my-app"), "GSD — my-app"); +}); + +test("formatNotificationTitle trims whitespace from project name", () => { + assert.equal(formatNotificationTitle(" spaced "), "GSD — spaced"); +}); + +test("buildDesktopNotificationCommand includes project name in title on linux", () => { + const command = buildDesktopNotificationCommand( + "linux", + formatNotificationTitle("my-project"), + "All milestones complete!", + "success", + ); + assert.ok(command); + assert.equal(command.args[2], "GSD — my-project"); + assert.equal(command.args[3], "All milestones complete!"); +}); + +test("buildDesktopNotificationCommand includes project name in title on macOS", () => { + const command = buildDesktopNotificationCommand( + "darwin", + formatNotificationTitle("my-project"), + "Budget 90%", + "warning", + ); + assert.ok(command); + if (command.file.includes("terminal-notifier")) { + const titleIdx = command.args.indexOf("-title"); + assert.equal(command.args[titleIdx + 1], "GSD — my-project"); + } else { + assert.match(command.args[1], /GSD — my-project/); + } +}); diff --git a/src/resources/extensions/gsd/undo.ts b/src/resources/extensions/gsd/undo.ts index 3d0c589b2..5d68c5e82 100644 --- a/src/resources/extensions/gsd/undo.ts +++ b/src/resources/extensions/gsd/undo.ts @@ -5,7 +5,7 @@ import type { ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent"; import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { join, basename } from "node:path"; import { nativeRevertCommit, nativeRevertAbort } from "./native-git-bridge.js"; import { parseUnitId } from "./unit-id.js"; import { deriveState } from "./state.js"; @@ -133,7 +133,7 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi } ctx.ui.notify(results.join("\n"), "success"); - sendDesktopNotification("GSD", `Undone: ${unitType} (${unitId})`, "info", "complete"); + sendDesktopNotification("GSD", `Undone: ${unitType} (${unitId})`, "info", "complete", basename(basePath)); } // ─── Targeted State Reset ────────────────────────────────────────────────────