feat: record sf chat workflow evidence
This commit is contained in:
parent
47867c1236
commit
7cb1eef948
3 changed files with 183 additions and 10 deletions
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue