diff --git a/src/resources/extensions/gsd/auto-observability.ts b/src/resources/extensions/gsd/auto-observability.ts deleted file mode 100644 index 0715a9ac4..000000000 --- a/src/resources/extensions/gsd/auto-observability.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Pre-dispatch observability checks for auto-mode units. - * Validates plan/summary file quality and builds repair instructions - * for the agent to fix gaps before proceeding with the unit. - */ - -import type { ExtensionContext } from "@gsd/pi-coding-agent"; -import { - validatePlanBoundary, - validateExecuteBoundary, - validateCompleteBoundary, - formatValidationIssues, -} from "./observability-validator.js"; -import type { ValidationIssue } from "./observability-validator.js"; -import { parseUnitId } from "./unit-id.js"; - -export async function collectObservabilityWarnings( - ctx: ExtensionContext, - basePath: string, - unitType: string, - unitId: string, -): Promise { - // Hook units have custom artifacts — skip standard observability checks - if (unitType.startsWith("hook/")) return []; - - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - - if (!mid || !sid) return []; - - let issues = [] as Awaited>; - - if (unitType === "plan-slice") { - issues = await validatePlanBoundary(basePath, mid, sid); - } else if (unitType === "execute-task" && tid) { - issues = await validateExecuteBoundary(basePath, mid, sid, tid); - } else if (unitType === "complete-slice") { - issues = await validateCompleteBoundary(basePath, mid, sid); - } - - if (issues.length > 0) { - ctx.ui.notify( - `Observability check (${unitType}) found ${issues.length} warning${issues.length === 1 ? "" : "s"}:\n${formatValidationIssues(issues)}`, - "warning", - ); - } - - return issues; -} - -export function buildObservabilityRepairBlock(issues: ValidationIssue[]): string { - if (issues.length === 0) return ""; - const items = issues.map(issue => { - const fileName = issue.file.split("/").pop() || issue.file; - let line = `- **${fileName}**: ${issue.message}`; - if (issue.suggestion) line += ` → ${issue.suggestion}`; - return line; - }); - return [ - "", - "---", - "", - "## Pre-flight: Observability gaps to fix FIRST", - "", - "The following issues were detected in plan/summary files for this unit.", - "**Read each flagged file, apply the fix described, then proceed with the unit.**", - "", - ...items, - "", - "---", - "", - ].join("\n"); -} diff --git a/src/resources/extensions/gsd/file-watcher.ts b/src/resources/extensions/gsd/file-watcher.ts deleted file mode 100644 index a8b0be19c..000000000 --- a/src/resources/extensions/gsd/file-watcher.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { FSWatcher } from "chokidar"; -import type { EventBus } from "@gsd/pi-coding-agent"; -import { relative } from "node:path"; - -let watcher: FSWatcher | null = null; -let pending = new Map>(); - -const EVENT_MAP: Record = { - "settings.json": "settings-changed", - "auth.json": "auth-changed", - "models.json": "models-changed", -}; - -const EXTENSIONS_DIR = "extensions"; - -const IGNORED_PATTERNS = [ - "**/sessions/**", - "**/*.tmp", - "**/*.swp", - "**/*~", - "**/.DS_Store", -]; - -const DEBOUNCE_MS = 300; - -/** - * Start watching `agentDir` (e.g. `~/.gsd/agent/`) for config changes. - * Emits events on the supplied EventBus when watched files are modified. - */ -export async function startFileWatcher( - agentDir: string, - eventBus: EventBus, -): Promise { - if (watcher) { - await watcher.close(); - } - - const { watch } = await import("chokidar"); - - pending = new Map>(); - - function debounceEmit(event: string): void { - const existing = pending.get(event); - if (existing) clearTimeout(existing); - pending.set( - event, - setTimeout(() => { - pending.delete(event); - eventBus.emit(event, { timestamp: Date.now() }); - }, DEBOUNCE_MS), - ); - } - - function resolveEvent(filePath: string): string | null { - const rel = relative(agentDir, filePath); - if (rel.startsWith("..")) return null; - - // Check direct file matches - for (const [file, event] of Object.entries(EVENT_MAP)) { - if (rel === file) return event; - } - - // Check extensions directory - if (rel.startsWith(EXTENSIONS_DIR + "/") || rel === EXTENSIONS_DIR) { - return "extensions-changed"; - } - - return null; - } - - watcher = watch(agentDir, { - ignoreInitial: true, - depth: 2, - ignored: IGNORED_PATTERNS, - }); - - for (const eventType of ["add", "change", "unlink"] as const) { - watcher.on(eventType, (filePath: string) => { - const event = resolveEvent(filePath); - if (event) debounceEmit(event); - }); - } - - // Wait for watcher to be ready - await new Promise((resolve) => { - watcher!.on("ready", resolve); - }); -} - -/** - * Stop the file watcher and clean up resources. - */ -export async function stopFileWatcher(): Promise { - for (const timer of pending.values()) clearTimeout(timer); - pending.clear(); - if (watcher) { - await watcher.close(); - watcher = null; - } -} diff --git a/src/resources/extensions/gsd/rtk-status.ts b/src/resources/extensions/gsd/rtk-status.ts deleted file mode 100644 index f3f519cdf..000000000 --- a/src/resources/extensions/gsd/rtk-status.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ExtensionContext } from "@gsd/pi-coding-agent"; -import { - ensureRtkSessionBaseline, - formatRtkSavingsLabel, - getRtkSessionSavings, -} from "../shared/rtk-session-stats.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; - -const STATUS_KEY = "gsd-rtk"; -const REFRESH_INTERVAL_MS = 30_000; - -let refreshTimer: ReturnType | null = null; - -function clearTimer(): void { - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } -} - -function isRtkEnabledInPrefs(): boolean { - return loadEffectiveGSDPreferences()?.preferences.experimental?.rtk === true; -} - -function updateStatus(ctx: ExtensionContext): void { - if (!ctx.hasUI) return; - if (!isRtkEnabledInPrefs()) return; - - const basePath = ctx.cwd; - const sessionId = ctx.sessionManager.getSessionId(); - ensureRtkSessionBaseline(basePath, sessionId); - const savings = getRtkSessionSavings(basePath, sessionId); - ctx.ui.setStatus(STATUS_KEY, formatRtkSavingsLabel(savings) ?? undefined); -} - -export function startRtkStatusUpdates(ctx: ExtensionContext): void { - clearTimer(); - if (!isRtkEnabledInPrefs()) { - // Ensure any previously set status is cleared (e.g. preference was toggled off) - ctx.ui.setStatus(STATUS_KEY, undefined); - return; - } - updateStatus(ctx); - if (!ctx.hasUI) return; - refreshTimer = setInterval(() => { - updateStatus(ctx); - }, REFRESH_INTERVAL_MS); -} - -export function stopRtkStatusUpdates(ctx?: ExtensionContext): void { - clearTimer(); - ctx?.ui.setStatus(STATUS_KEY, undefined); -} diff --git a/src/tests/file-watcher.test.ts b/src/tests/file-watcher.test.ts deleted file mode 100644 index cdfcee6af..000000000 --- a/src/tests/file-watcher.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { test, afterEach } from "node:test"; -import assert from "node:assert"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { setTimeout as delay } from "node:timers/promises"; - -import { - startFileWatcher, - stopFileWatcher, -} from "../resources/extensions/gsd/file-watcher.ts"; - -function createTempAgentDir(): string { - const tmp = mkdtempSync(join(tmpdir(), "gsd-fw-test-")); - mkdirSync(join(tmp, "extensions"), { recursive: true }); - // Seed watched files so chokidar treats writes as "change" not "add" - writeFileSync(join(tmp, "settings.json"), "{}"); - writeFileSync(join(tmp, "auth.json"), "{}"); - writeFileSync(join(tmp, "models.json"), "{}"); - return tmp; -} - -function createMockEventBus() { - const events: { channel: string; data: unknown }[] = []; - return { - events, - emit(channel: string, data: unknown) { - events.push({ channel, data }); - }, - on(_channel: string, _handler: (data: unknown) => void) { - return () => {}; - }, - }; -} - -afterEach(async () => { - await stopFileWatcher(); -}); - -test("startFileWatcher and stopFileWatcher run without errors", async () => { - const dir = createTempAgentDir(); - const bus = createMockEventBus(); - - await startFileWatcher(dir, bus); - await stopFileWatcher(); -}); - -test("stopFileWatcher is safe to call when no watcher is active", async () => { - await stopFileWatcher(); -}); - -test("settings.json change emits settings-changed event", async () => { - const dir = createTempAgentDir(); - const bus = createMockEventBus(); - - await startFileWatcher(dir, bus); - await delay(200); - - writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true })); - // Wait for debounce (300ms) + filesystem propagation - await delay(800); - - const matched = bus.events.filter((e) => e.channel === "settings-changed"); - assert.ok(matched.length > 0, "should emit settings-changed event"); -}); - -test("auth.json change emits auth-changed event", async () => { - const dir = createTempAgentDir(); - const bus = createMockEventBus(); - - await startFileWatcher(dir, bus); - // Allow watcher to fully initialize before writing - await delay(200); - - writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" })); - await delay(800); - - const matched = bus.events.filter((e) => e.channel === "auth-changed"); - assert.ok(matched.length > 0, "should emit auth-changed event"); -}); - -test("models.json change emits models-changed event", async () => { - const dir = createTempAgentDir(); - const bus = createMockEventBus(); - - await startFileWatcher(dir, bus); - await delay(200); - - writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" })); - await delay(800); - - const matched = bus.events.filter((e) => e.channel === "models-changed"); - assert.ok(matched.length > 0, "should emit models-changed event"); -}); - -test("extensions directory change emits extensions-changed event", { skip: process.platform === "win32" ? "chokidar subdirectory events are unreliable on Windows CI" : undefined }, async () => { - const dir = createTempAgentDir(); - const bus = createMockEventBus(); - - await startFileWatcher(dir, bus); - await delay(500); - - writeFileSync( - join(dir, "extensions", "my-ext.json"), - JSON.stringify({ name: "test" }), - ); - await delay(2000); - - const matched = bus.events.filter( - (e) => e.channel === "extensions-changed", - ); - assert.ok(matched.length > 0, "should emit extensions-changed event"); -}); - -test("unrelated file changes are ignored", async () => { - const dir = createTempAgentDir(); - const bus = createMockEventBus(); - - await startFileWatcher(dir, bus); - // Wait for watcher to settle, then clear any residual events from setup - await delay(400); - bus.events.length = 0; - - writeFileSync(join(dir, "random.txt"), "hello"); - await delay(600); - - assert.strictEqual(bus.events.length, 0, "should not emit any events"); -}); - -test("debouncing coalesces rapid changes into one event", async () => { - const dir = createTempAgentDir(); - const bus = createMockEventBus(); - - await startFileWatcher(dir, bus); - - // Rapid-fire writes - for (let i = 0; i < 5; i++) { - writeFileSync(join(dir, "settings.json"), JSON.stringify({ i })); - } - await delay(800); - - const matched = bus.events.filter((e) => e.channel === "settings-changed"); - assert.strictEqual( - matched.length, - 1, - "rapid changes should be debounced into a single event", - ); -});