feat: record sf chat workflow evidence

This commit is contained in:
Mikael Hugo 2026-05-14 20:27:53 +02:00
parent 47867c1236
commit 7cb1eef948
3 changed files with 183 additions and 10 deletions

View file

@ -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,
};
});
}

View file

@ -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,
});
}
}

View file

@ -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", () => {