From 7cb1eef9485ccd60995cb7f5a3c2edc87de2c123 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 20:27:53 +0200 Subject: [PATCH] feat: record sf chat workflow evidence --- .../extensions/sf/chat-command-router.js | 66 +++++++++++++-- src/resources/extensions/sf/commands/index.js | 80 +++++++++++++++++++ .../sf/tests/direct-command-surface.test.mjs | 47 ++++++++++- 3 files changed, 183 insertions(+), 10 deletions(-) diff --git a/src/resources/extensions/sf/chat-command-router.js b/src/resources/extensions/sf/chat-command-router.js index 5bc061bd4..6bdb3e45a 100644 --- a/src/resources/extensions/sf/chat-command-router.js +++ b/src/resources/extensions/sf/chat-command-router.js @@ -1,3 +1,7 @@ +import { randomUUID } from "node:crypto"; +import { emitJournalEvent } from "./journal.js"; +import { appendTraceEvent } from "./uok/trace-writer.js"; + const ROUTES = [ { command: "help", @@ -96,7 +100,7 @@ function normalizeArgs(text) { return text.replace(/\s+/g, " ").trim(); } -export function routeChatCommand(text) { +export function classifyChatCommand(text) { const input = normalizeArgs(text); if (!input || input.startsWith("/") || input.startsWith("!")) return null; if (input.length > 240) return null; @@ -104,16 +108,62 @@ export function routeChatCommand(text) { for (const route of ROUTES) { if (!route.patterns.some((pattern) => pattern.test(input))) continue; const args = route.preserveArgs ? input : ""; - return args ? `/${route.command} ${args}` : `/${route.command}`; + return { + input, + command: route.command, + args, + routedText: args ? `/${route.command} ${args}` : `/${route.command}`, + confidence: "high", + outcome: "routed", + nextAction: `execute /${route.command}`, + }; } return null; } -export function registerChatCommandRouter(pi) { - pi.on("input", (event) => { - if (event.images?.length > 0) return { action: "continue" }; - const routed = routeChatCommand(event.text); - if (!routed) return { action: "continue" }; - return { action: "transform", text: routed, images: event.images }; +export function routeChatCommand(text) { + return classifyChatCommand(text)?.routedText ?? null; +} + +function emitChatIntentEvidence(basePath, decision) { + if (!basePath || !decision) return; + const flowId = `chat-intent:${randomUUID()}`; + const event = { + input: decision.input, + routedText: decision.routedText, + command: decision.command, + args: decision.args, + confidence: decision.confidence, + outcome: decision.outcome, + nextAction: decision.nextAction, + }; + emitJournalEvent(basePath, { + ts: new Date().toISOString(), + flowId, + seq: 1, + eventType: "chat-intent-routed", + rule: "chat-command-router", + data: event, + }); + appendTraceEvent(basePath, flowId, { + type: "chat_intent", + surface: "interactive", + runControl: "assisted", + permissionProfile: "normal", + ...event, + }); +} + +export function registerChatCommandRouter(pi) { + pi.on("input", (event, ctx) => { + if (event.images?.length > 0) return { action: "continue" }; + const decision = classifyChatCommand(event.text); + if (!decision) return { action: "continue" }; + emitChatIntentEvidence(ctx?.cwd, decision); + return { + action: "transform", + text: decision.routedText, + images: event.images, + }; }); } diff --git a/src/resources/extensions/sf/commands/index.js b/src/resources/extensions/sf/commands/index.js index ca826591f..b2cf5166f 100644 --- a/src/resources/extensions/sf/commands/index.js +++ b/src/resources/extensions/sf/commands/index.js @@ -1,4 +1,8 @@ +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { importExtensionModule } from "@singularity-forge/coding-agent"; +import { emitJournalEvent } from "../journal.js"; +import { appendTraceEvent } from "../uok/trace-writer.js"; import { DIRECT_SF_COMMANDS, getSfTopLevelCommandCompletions, @@ -6,6 +10,58 @@ import { SF_COMMAND_DESCRIPTION, } from "./catalog.js"; +function listChangedFiles(cwd) { + if (!cwd) return []; + const result = spawnSync("git", ["status", "--porcelain=v1"], { + cwd, + encoding: "utf8", + timeout: 3000, + }); + if (result.status !== 0 || !result.stdout) return []; + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^.. /, "")) + .sort(); +} + +function nextActionForCommand(command, outcome) { + if (outcome === "fail") + return "inspect blocker and retry or switch to /discuss"; + if (command === "autonomous") return "monitor /status or /queue"; + if (command === "next") + return "review evidence, then run /next or /autonomous"; + if (command === "discuss") return "continue discussion until plan is ready"; + if (command === "quick") return "review result and decide whether to promote"; + if (command === "ship") return "review PR/ship output"; + return "continue workflow"; +} + +function emitCommandEvidence(basePath, traceId, evidence) { + if (!basePath) return; + emitJournalEvent(basePath, { + ts: new Date().toISOString(), + flowId: traceId, + seq: 1, + eventType: "workflow-command-complete", + rule: "direct-sf-command", + data: evidence, + }); + appendTraceEvent(basePath, traceId, { + type: "workflow_command_completion", + surface: "interactive", + runControl: + evidence.command === "autonomous" + ? "autonomous" + : evidence.command === "next" + ? "assisted" + : "manual", + permissionProfile: "normal", + ...evidence, + }); +} + async function dispatchDirectSFCommand(command, args, ctx, pi) { const { handleSFCommand } = await importExtensionModule( import.meta.url, @@ -16,14 +72,38 @@ async function dispatchDirectSFCommand(command, args, ctx, pi) { "../workflow-logger.js", ); const previousStderrSetting = setStderrLoggingEnabled(false); + const basePath = ctx?.cwd ?? process.cwd(); + const traceId = `workflow-command:${command}:${randomUUID()}`; + const changedBefore = new Set(listChangedFiles(basePath)); + const startedAt = Date.now(); + let outcome = "pass"; + let blocker = null; try { const suffix = typeof args === "string" && args.trim().length > 0 ? ` ${args.trim()}` : ""; await handleSFCommand(`${command}${suffix}`, ctx, pi); + } catch (err) { + outcome = "fail"; + blocker = err instanceof Error ? err.message : String(err); + throw err; } finally { setStderrLoggingEnabled(previousStderrSetting); + const changedAfter = listChangedFiles(basePath); + const newChangedFiles = changedAfter.filter( + (file) => !changedBefore.has(file), + ); + emitCommandEvidence(basePath, traceId, { + command, + args: typeof args === "string" ? args.trim() : "", + outcome, + changedFiles: changedAfter, + newChangedFiles, + blockers: blocker ? [blocker] : [], + nextAction: nextActionForCommand(command, outcome), + durationMs: Date.now() - startedAt, + }); } } diff --git a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs index f1c433f77..ef7a28081 100644 --- a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs +++ b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs @@ -1,9 +1,14 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "vitest"; import guardrails from "../../guardrails/index.js"; -import { routeChatCommand } from "../chat-command-router.js"; +import { + classifyChatCommand, + registerChatCommandRouter, + routeChatCommand, +} from "../chat-command-router.js"; import { DIRECT_SF_COMMAND_NAMES, getSfArgumentCompletions, @@ -12,6 +17,7 @@ import { } from "../commands/catalog.js"; import { showHelp } from "../commands/handlers/core.js"; import { registerSFCommands } from "../commands/index.js"; +import { queryJournal } from "../journal.js"; test("direct SF command surface registers workflow verbs without legacy sf namespace", () => { const registered = []; @@ -81,6 +87,43 @@ test("chat_command_router_maps_clear_chat_intent_to_public_workflow_commands", ( assert.equal(routeChatCommand("let's discuss the architecture"), "/discuss"); assert.equal(routeChatCommand("fix the login bug"), null); assert.equal(routeChatCommand("/queue"), null); + + assert.deepEqual(classifyChatCommand("I want to see my queue"), { + input: "I want to see my queue", + command: "queue", + args: "", + routedText: "/queue", + confidence: "high", + outcome: "routed", + nextAction: "execute /queue", + }); +}); + +test("chat_command_router_emits_journal_evidence_for_routed_intent", async () => { + const dir = mkdtempSync(join(tmpdir(), "sf-chat-route-")); + try { + let inputHandler; + registerChatCommandRouter({ + on(event, handler) { + if (event === "input") inputHandler = handler; + }, + }); + + const result = await inputHandler( + { text: "I want to see my queue", images: undefined }, + { cwd: dir }, + ); + const entries = queryJournal(dir, { eventType: "chat-intent-routed" }); + + assert.equal(result.action, "transform"); + assert.equal(result.text, "/queue"); + assert.equal(entries.length, 1); + assert.equal(entries[0].data.command, "queue"); + assert.equal(entries[0].data.outcome, "routed"); + assert.equal(entries[0].data.nextAction, "execute /queue"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); test("direct command completions strip the already typed command name", () => {