82263a0ab5
Two defects that made the 16-node ship-feature pipeline look like stage detection was broken: - stage-detect matched each rule against a JOIN of the tool input's identifying fields. An Agent event really carries description + prompt + subagent_type, so `^Explore$` (intake) and `^Plan$` (plan) could never fire and both nodes shipped inert — the map jumped straight to `implementing`. Rules are now tested one field at a time, which also stops a rule matching across the seam between two unrelated fields. - `detectionLeadsDeclaration` (bin/ccam.js) and `detectionLeads` (LaneStripCard.tsx) located the declaration with `indexOf(lane.stage)`. Most of what the skill declares is an ALIAS (`tests`, `booting`, `ship`, `gate-blocked`), which is not a node id, so indexOf answered -1 and every detection — however far behind — was badged as leading. Both now use the node the server already resolved it onto (`state === "current"`). server 1346/1346, client 447/447.
170 lines
5.9 KiB
JavaScript
170 lines
5.9 KiB
JavaScript
/**
|
|
* @file Matches hook events against pipeline-defined stage-detection rules.
|
|
* Inputs and templates are untrusted, so each exported function is total and
|
|
* compiled regular expressions are cached once for each pipeline object.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const compiledPipelines = new WeakMap();
|
|
|
|
// Only fields that identify WHAT a tool did, never editor payload like
|
|
// old_string/new_string, which can be whole code blocks.
|
|
const FLATTEN_KEYS = new Set([
|
|
"command",
|
|
"file_path",
|
|
"skill",
|
|
"prompt",
|
|
"pattern",
|
|
"description",
|
|
"subagent_type",
|
|
]);
|
|
|
|
const SIGNAL_MAX = 120;
|
|
|
|
/** Collapse whitespace and cap to a bounded, readable length. */
|
|
function capSignal(text) {
|
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
return collapsed.length > SIGNAL_MAX ? `${collapsed.slice(0, SIGNAL_MAX)}…` : collapsed;
|
|
}
|
|
|
|
/** Characters of surrounding context kept on each side of a matched span. */
|
|
const SIGNAL_CONTEXT = 24;
|
|
|
|
/**
|
|
* The span the rule actually matched, plus a little context — the whole
|
|
* flattened input is usually a long shell line whose interesting part is a few
|
|
* words in the middle (`cd /very/long/path && npm run test:server 2>&1 | tail`).
|
|
* A rule with no regex matched on the tool name alone and has no span, so the
|
|
* caller keeps the flattened input.
|
|
*/
|
|
function matchedSpan(regex, input) {
|
|
if (!regex) return null;
|
|
// `regex` may carry /g from a user template; lastIndex would make exec()
|
|
// stateful across calls, so search from a known position every time.
|
|
regex.lastIndex = 0;
|
|
const m = regex.exec(input);
|
|
if (!m || typeof m.index !== "number") return null;
|
|
const start = Math.max(0, m.index - SIGNAL_CONTEXT);
|
|
const end = Math.min(input.length, m.index + m[0].length + SIGNAL_CONTEXT);
|
|
const prefix = start > 0 ? "…" : "";
|
|
const suffix = end < input.length ? "…" : "";
|
|
return `${prefix}${input.slice(start, end)}${suffix}`;
|
|
}
|
|
|
|
/**
|
|
* Known identifying string fields of a tool input, kept SEPARATE, without
|
|
* recursively walking it.
|
|
*
|
|
* Rules are tested one field at a time (see detect) rather than against a join
|
|
* of them. An `Agent` input really carries `{description, prompt,
|
|
* subagent_type}`, so on a joined string a rule anchored to the agent's
|
|
* identity — `^Explore$`, `^Plan$` in ship-feature.json — can never match: the
|
|
* anchors sit against the description and the prompt instead. Per field they
|
|
* match the thing they were written for, and no rule can match across the seam
|
|
* between two unrelated fields.
|
|
*/
|
|
function inputFields(toolInput) {
|
|
try {
|
|
if (typeof toolInput === "string") return [toolInput];
|
|
if (toolInput === null || typeof toolInput !== "object") return [];
|
|
|
|
const strings = [];
|
|
if (Array.isArray(toolInput)) {
|
|
for (const value of toolInput) {
|
|
if (typeof value === "string") strings.push(value);
|
|
else if (value && typeof value === "object") {
|
|
for (const [key, nestedValue] of Object.entries(value)) {
|
|
if (FLATTEN_KEYS.has(key) && typeof nestedValue === "string") strings.push(nestedValue);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (const [key, value] of Object.entries(toolInput)) {
|
|
if (FLATTEN_KEYS.has(key) && typeof value === "string") strings.push(value);
|
|
}
|
|
}
|
|
return strings;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** The same fields as one string — the human-readable signal, and the shape
|
|
* the ingest paths log. Never what a rule is matched against. */
|
|
function flattenInput(toolInput) {
|
|
return inputFields(toolInput).join(" ");
|
|
}
|
|
|
|
/** Compile valid node rules once, omitting malformed nodes and regex patterns. */
|
|
function compileRules(pipeline) {
|
|
if (!pipeline || typeof pipeline !== "object") return [];
|
|
const cached = compiledPipelines.get(pipeline);
|
|
if (cached) return cached;
|
|
|
|
let nodes = [];
|
|
try {
|
|
nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const compiled = nodes.map((node) => {
|
|
const rules = [];
|
|
try {
|
|
const detectRules = Array.isArray(node && node.detect) ? node.detect : [];
|
|
for (const rule of detectRules) {
|
|
if (!rule || typeof rule !== "object" || typeof rule.tool !== "string") continue;
|
|
if (rule.match === undefined) {
|
|
rules.push({ tool: rule.tool, regex: null });
|
|
continue;
|
|
}
|
|
if (typeof rule.match !== "string") continue;
|
|
try {
|
|
rules.push({ tool: rule.tool, regex: new RegExp(rule.match) });
|
|
} catch {
|
|
// A user-supplied invalid regex must never block hook ingestion.
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore a malformed node while preserving the remaining template.
|
|
}
|
|
return { nodeId: node && node.id, rules };
|
|
});
|
|
|
|
compiledPipelines.set(pipeline, compiled);
|
|
return compiled;
|
|
}
|
|
|
|
/** Infer the last matching pipeline stage from one hook event, or return null. */
|
|
function detect(pipeline, event) {
|
|
try {
|
|
if (!event || typeof event.tool_name !== "string" || !event.tool_name) return null;
|
|
const fields = inputFields(event.tool_input);
|
|
const fallback = fields.join(" ") || event.tool_name;
|
|
let match = null;
|
|
|
|
for (const node of compileRules(pipeline)) {
|
|
if (typeof node.nodeId !== "string" || !node.nodeId) continue;
|
|
for (const rule of node.rules) {
|
|
if (rule.tool !== event.tool_name) continue;
|
|
// A rule with no regex matched on the tool name alone.
|
|
const hit = rule.regex
|
|
? fields.find((f) => {
|
|
rule.regex.lastIndex = 0; // a /g rule from a user template is stateful
|
|
return rule.regex.test(f);
|
|
})
|
|
: "";
|
|
if (hit === undefined) continue;
|
|
const span = hit ? matchedSpan(rule.regex, hit) : null;
|
|
match = { nodeId: node.nodeId, signal: `\`${capSignal(span || fallback)}\`` };
|
|
break;
|
|
}
|
|
}
|
|
return match;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = { flattenInput, inputFields, compileRules, detect };
|