feat(lanes): drive runs from the pipeline template's own skill
A pipeline template may now declare the slash command that drives it (`skill`), so the choice of pipeline is the choice of workflow rather than just a picture on the dashboard: - `ccam ship-full "<req>"` runs the 16-node `ship-feature` template's skill (`/ccam:ship-feature-lane`) — through gates, QC and the senior gate to a published PR, watched until a human merges it. - `ccam ship-quick "<req>"` runs the 8-node `default` template's skill (`/ccam:ship-feature`) — in-repo feature work that stops at the gate. Each command puts the lane on its own template before spawning, so the dashboard renders the right node count from the first stage instead of after the skill self-corrects. `--print` shows the command, switches nothing and runs nothing. The prompt reaches `claude` as one argv element, never through a shell. The same field feeds the Workspace run console, which pre-fills its prompt with the lane's driving skill and never overwrites a prompt the user has already edited.
This commit is contained in:
+96
@@ -2278,6 +2278,7 @@ async function cmdLanesPipeline(args) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
@@ -2325,6 +2326,88 @@ async function cmdLanesShell(args) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a `claude` session in this lane driven by a pipeline's own skill.
|
||||
* Two commands, two depths of work:
|
||||
*
|
||||
* ccam ship-full → the 16-node `ship-feature` template, `/ccam:ship-feature-lane`
|
||||
* — autonomous to a published PR, then watched until a
|
||||
* human merges it.
|
||||
* ccam ship-quick → the 8-node `default` template, `/ccam:ship-feature`
|
||||
* — in-repo feature work that stops at review/gate.
|
||||
*
|
||||
* Each command puts the lane on its own template, so the choice is the command
|
||||
* you type; the skill string itself lives in the template's `skill` field and
|
||||
* is never duplicated here.
|
||||
*
|
||||
* The prompt is passed as ONE argv element to `claude`, never through a shell —
|
||||
* a requirement containing quotes, `$`, or `;` is text, not code. `--print`
|
||||
* shows the exact command, switches nothing, and runs nothing.
|
||||
*/
|
||||
const SHIP_TEMPLATES = {
|
||||
"ship-full": "ship-feature",
|
||||
"ship-quick": "default",
|
||||
};
|
||||
|
||||
async function cmdShip(command, args) {
|
||||
const templateId = SHIP_TEMPLATES[command];
|
||||
const { flags, positional } = parseArgs(args);
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
let { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const skill = pipelines.find((p) => p.id === templateId)?.skill;
|
||||
if (!skill) {
|
||||
console.error(
|
||||
`✖ pipeline template "${templateId}" declares no driving skill — ` +
|
||||
`add a "skill" field to it (see docs/LANES.md § Custom template format)`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// `resolveLaneArg` only strips a leading bare lane id; the rest of the
|
||||
// positionals are the requirement, joined back into one prompt line.
|
||||
const requirement = positional
|
||||
.filter((p) => p !== String(resolved.laneId))
|
||||
.join(" ")
|
||||
.trim();
|
||||
const prompt = requirement ? `${skill} ${requirement}` : skill;
|
||||
|
||||
if (flags.print || !requirement) {
|
||||
// `--print` shows what would run, so it switches nothing.
|
||||
if (!requirement) console.error(`usage: ccam ${command} "<requirement>" [<id>] [--print]`);
|
||||
console.log(`cd ${lane.cwd} && claude ${JSON.stringify(prompt)}`);
|
||||
if (!requirement) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// The command picks the pipeline, not the lane's current setting: put the
|
||||
// lane on the matching template first so the dashboard renders the right
|
||||
// node count from the start rather than after the skill self-corrects.
|
||||
if (lane.pipeline !== templateId) {
|
||||
({ lane } = await patch(`/api/lanes/${lane.id}`, { pipeline: templateId }));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${c.dim(`lane #${lane.id} · pipeline ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`)}\n` +
|
||||
`${c.cyan("→")} claude ${JSON.stringify(prompt)}`
|
||||
);
|
||||
const child = spawn("claude", [prompt], { stdio: "inherit", cwd: lane.cwd });
|
||||
await new Promise((resolve) => {
|
||||
child.on("close", (code) => {
|
||||
if (code) process.exitCode = code;
|
||||
resolve();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
console.error(c.red(`✖ ${err?.message || err}`));
|
||||
process.exitCode = 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cmdFeatureShow(args) {
|
||||
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!slug) {
|
||||
@@ -2509,6 +2592,16 @@ const COMMAND_GROUPS = [
|
||||
"[<template-id>] [<id>]",
|
||||
"Show, or switch, which pipeline template a lane renders against",
|
||||
],
|
||||
[
|
||||
"ship-full",
|
||||
'"<requirement>" [<id>] [--print]',
|
||||
"Run the 16-stage lane pipeline: implement → gates → e2e → review → QC → senior gate → PR, watched until a human merges (--print: show the command, run nothing)",
|
||||
],
|
||||
[
|
||||
"ship-quick",
|
||||
'"<requirement>" [<id>] [--print]',
|
||||
"Run the 8-stage in-repo pipeline: plan → implement → tests → review → gate → ship. No PR, no browser QC",
|
||||
],
|
||||
[
|
||||
"lanes shell",
|
||||
"[<id>]",
|
||||
@@ -3418,6 +3511,9 @@ async function runCommand(argv) {
|
||||
}
|
||||
case "stage":
|
||||
return cmdStage(rest);
|
||||
case "ship-full":
|
||||
case "ship-quick":
|
||||
return cmdShip(cmd, rest);
|
||||
case "feature": {
|
||||
const sub = rest[0];
|
||||
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
||||
|
||||
Reference in New Issue
Block a user