Files
AgentHub/server/lib/stage-detect.js
T
nntrivi2001 82263a0ab5 fix(lanes): revive dead detect rules and stop alias-blind detection badges
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.
2026-08-28 10:05:24 +07:00

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 };