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.
This commit is contained in:
2026-08-28 10:05:24 +07:00
parent fd7f57929c
commit 82263a0ab5
6 changed files with 218 additions and 27 deletions
+37 -14
View File
@@ -51,11 +51,22 @@ function matchedSpan(regex, input) {
return `${prefix}${input.slice(start, end)}${suffix}`;
}
/** Return known identifying string fields from a tool input without recursively walking it. */
function flattenInput(toolInput) {
/**
* 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 "";
if (typeof toolInput === "string") return [toolInput];
if (toolInput === null || typeof toolInput !== "object") return [];
const strings = [];
if (Array.isArray(toolInput)) {
@@ -72,12 +83,18 @@ function flattenInput(toolInput) {
if (FLATTEN_KEYS.has(key) && typeof value === "string") strings.push(value);
}
}
return strings.join(" ");
return strings;
} catch {
return "";
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 [];
@@ -122,19 +139,25 @@ function compileRules(pipeline) {
function detect(pipeline, event) {
try {
if (!event || typeof event.tool_name !== "string" || !event.tool_name) return null;
const input = flattenInput(event.tool_input);
const fallback = input || event.tool_name;
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;
if (!rule.regex || rule.regex.test(input)) {
const span = matchedSpan(rule.regex, input);
match = { nodeId: node.nodeId, signal: `\`${capSignal(span || fallback)}\`` };
break;
}
// 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;
@@ -143,4 +166,4 @@ function detect(pipeline, event) {
}
}
module.exports = { flattenInput, compileRules, detect };
module.exports = { flattenInput, inputFields, compileRules, detect };