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:
@@ -95,6 +95,29 @@ test("flattenInput handles awkward tool-input shapes without traversing deeply",
|
||||
assert.equal(flattenInput({ nested: { command: "never recurse" } }), "");
|
||||
});
|
||||
|
||||
test("matches a rule per field, so an anchored rule is not killed by its neighbours", () => {
|
||||
// A real Agent input carries {description, prompt, subagent_type}. Matched
|
||||
// against a join of the three, `^Explore$` can never fire and the intake and
|
||||
// plan rules ship inert; matched per field it hits subagent_type verbatim.
|
||||
const anchored = {
|
||||
nodes: [{ id: "intake", detect: [{ tool: "Agent", match: "^Explore$" }] }],
|
||||
};
|
||||
const event = {
|
||||
tool_name: "Agent",
|
||||
tool_input: {
|
||||
description: "Find the stage rules",
|
||||
prompt: "Search the repo for stage detection rules and report back.",
|
||||
subagent_type: "Explore",
|
||||
},
|
||||
};
|
||||
assert.equal(detect(anchored, event).nodeId, "intake");
|
||||
|
||||
// The seam between two fields is not matchable either — "rules Search" only
|
||||
// exists once description and prompt are glued together.
|
||||
const seam = { nodes: [{ id: "intake", detect: [{ tool: "Agent", match: "rules Search" }] }] };
|
||||
assert.equal(detect(seam, event), null);
|
||||
});
|
||||
|
||||
test("caps a long signal so it never inflates a DB column or a title attribute", () => {
|
||||
const longCommand = `git push ${"x".repeat(4990)}`;
|
||||
const result = detect(pipeline, { tool_name: "Bash", tool_input: { command: longCommand } });
|
||||
@@ -512,6 +535,22 @@ test.describe("ship-feature pipeline template", () => {
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const cases = [
|
||||
["intake", "Skill", { skill: "superpowers:brainstorming" }],
|
||||
// Agent inputs always arrive with a description and a prompt alongside
|
||||
// the type, which is exactly what these two anchored rules must survive.
|
||||
[
|
||||
"intake",
|
||||
"Agent",
|
||||
{
|
||||
description: "Scout the lane code",
|
||||
prompt: "Read-only sweep.",
|
||||
subagent_type: "Explore",
|
||||
},
|
||||
],
|
||||
[
|
||||
"plan",
|
||||
"Agent",
|
||||
{ description: "Design the fix", prompt: "Propose an approach.", subagent_type: "Plan" },
|
||||
],
|
||||
["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
|
||||
["plan", "Skill", { skill: "superpowers:writing-plans" }],
|
||||
["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
|
||||
|
||||
+37
-14
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user