feat(lanes): drive runs from the pipeline template's own skill #9
+96
@@ -2278,6 +2278,7 @@ async function cmdLanesPipeline(args) {
|
|||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/lanes/pipelines");
|
||||||
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
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(", ")}`);
|
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||||
return;
|
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) {
|
async function cmdFeatureShow(args) {
|
||||||
const slug = args.find((arg) => !arg.startsWith("--"));
|
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -2509,6 +2592,16 @@ const COMMAND_GROUPS = [
|
|||||||
"[<template-id>] [<id>]",
|
"[<template-id>] [<id>]",
|
||||||
"Show, or switch, which pipeline template a lane renders against",
|
"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",
|
"lanes shell",
|
||||||
"[<id>]",
|
"[<id>]",
|
||||||
@@ -3418,6 +3511,9 @@ async function runCommand(argv) {
|
|||||||
}
|
}
|
||||||
case "stage":
|
case "stage":
|
||||||
return cmdStage(rest);
|
return cmdStage(rest);
|
||||||
|
case "ship-full":
|
||||||
|
case "ship-quick":
|
||||||
|
return cmdShip(cmd, rest);
|
||||||
case "feature": {
|
case "feature": {
|
||||||
const sub = rest[0];
|
const sub = rest[0];
|
||||||
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export function LaneConsolePane({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { activeRuns: runs } = latest.current;
|
const { activeRuns: runs } = latest.current;
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
lastPrefill.current = "";
|
||||||
setResumeSession(null);
|
setResumeSession(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
@@ -129,6 +130,23 @@ export function LaneConsolePane({
|
|||||||
refreshList();
|
refreshList();
|
||||||
}, [laneId, refreshList]);
|
}, [laneId, refreshList]);
|
||||||
|
|
||||||
|
// Pre-fill the prompt with the slash command the lane's pipeline template
|
||||||
|
// names as its driver, so picking the 16-node pipeline starts the 16-step
|
||||||
|
// skill and the 8-node one the 8-step skill. Only overwrites a prompt the
|
||||||
|
// user hasn't touched (still exactly the previous prefill), so switching
|
||||||
|
// templates never eats a typed requirement. Must be declared AFTER the
|
||||||
|
// lane-switch effect: on a lane change that one clears both first.
|
||||||
|
const lastPrefill = useRef("");
|
||||||
|
const pipelineSkill = currentLane?.pipeline_skill || "";
|
||||||
|
useEffect(() => {
|
||||||
|
const next = pipelineSkill ? `${pipelineSkill} ` : "";
|
||||||
|
setPrompt((p) => (p === lastPrefill.current ? next : p));
|
||||||
|
lastPrefill.current = next;
|
||||||
|
// `laneId` is a dep too: switching between two lanes on the SAME template
|
||||||
|
// leaves `pipelineSkill` unchanged, and the switch effect just cleared the
|
||||||
|
// prompt — without it the new lane would open with an empty box.
|
||||||
|
}, [pipelineSkill, laneId]);
|
||||||
|
|
||||||
// The switch effect above only sees whatever `activeRuns` the page already
|
// The switch effect above only sees whatever `activeRuns` the page already
|
||||||
// had loaded at that instant. Remounting this page (navigating away and
|
// had loaded at that instant. Remounting this page (navigating away and
|
||||||
// back) starts `activeRuns` at null again, so a lane with a live run would
|
// back) starts `activeRuns` at null again, so a lane with a live run would
|
||||||
|
|||||||
@@ -188,6 +188,24 @@ describe("LaneConsolePane", () => {
|
|||||||
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pre-fills the prompt with the pipeline's driving skill, and keeps a typed prompt when the template changes", async () => {
|
||||||
|
const lane = { ...LANE, pipeline_skill: "/ccam:ship-feature" };
|
||||||
|
const props = { ...baseProps(), lanes: [lane] };
|
||||||
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||||
|
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
||||||
|
await waitFor(() => expect(prompt.value).toBe("/ccam:ship-feature "));
|
||||||
|
|
||||||
|
// Untouched prefill follows the template switch.
|
||||||
|
const laneShip = { ...lane, pipeline_skill: "/ccam:ship-feature-lane" };
|
||||||
|
rerender(<LaneConsolePane {...props} lanes={[laneShip]} />);
|
||||||
|
await waitFor(() => expect(prompt.value).toBe("/ccam:ship-feature-lane "));
|
||||||
|
|
||||||
|
// A typed requirement is never eaten by a later template switch.
|
||||||
|
fireEvent.change(prompt, { target: { value: "/ccam:ship-feature-lane add a button" } });
|
||||||
|
rerender(<LaneConsolePane {...props} lanes={[lane]} />);
|
||||||
|
await waitFor(() => expect(prompt.value).toBe("/ccam:ship-feature-lane add a button"));
|
||||||
|
});
|
||||||
|
|
||||||
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||||
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
||||||
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -2303,6 +2303,10 @@ export interface Lane {
|
|||||||
stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>;
|
stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
pipeline_name: string;
|
pipeline_name: string;
|
||||||
|
/** Slash command the lane's pipeline template declares as its driver, e.g.
|
||||||
|
* `/ccam:ship-feature-lane`. The run console pre-fills its prompt with it.
|
||||||
|
* Empty when the template names no skill. */
|
||||||
|
pipeline_skill?: string;
|
||||||
pipeline_nodes: LaneNode[];
|
pipeline_nodes: LaneNode[];
|
||||||
progress: number;
|
progress: number;
|
||||||
stage_seconds: number | null;
|
stage_seconds: number | null;
|
||||||
|
|||||||
@@ -238,11 +238,15 @@ A lane is a durable unit of parallel agent work — one working directory, many
|
|||||||
| `ccam lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
|
| `ccam lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
|
||||||
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
||||||
| `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
|
| `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
|
||||||
|
| `ccam ship-full "<requirement>" [<id>] [--print]` | **The 16-stage lane pipeline.** Puts the lane on the `ship-feature` template and starts a `claude` session running its skill (`/ccam:ship-feature-lane`): implement → gates → e2e → review → local QC → senior GO/NO-GO → push branch + open PR, then watches the PR until a human merges it. Use when the work should end in a reviewable PR |
|
||||||
|
| `ccam ship-quick "<requirement>" [<id>] [--print]` | **The 8-stage in-repo pipeline.** Puts the lane on the `default` template and runs its skill (`/ccam:ship-feature`): plan → implement → tests → review → gate → ship. No PR, no browser QC. Use for work that stays in the working copy |
|
||||||
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it |
|
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it |
|
||||||
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
|
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
|
||||||
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
||||||
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
|
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
|
||||||
|
|
||||||
|
`ccam ship-full` / `ccam ship-quick` both resolve the lane from the working directory (or take a lane id), pass the requirement to `claude` as one argv element — never through a shell — and accept `--print`, which shows the exact command, switches no template and runs nothing. The skill string itself lives in the template's `skill` field; a template without one makes the command refuse rather than guess.
|
||||||
|
|
||||||
|
|
||||||
**Runtime** — the lane's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one lane id. Each requires the repository to declare a profile at `<repo>/.ccam/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
|
**Runtime** — the lane's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one lane id. Each requires the repository to declare a profile at `<repo>/.ccam/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
|
||||||
|
|
||||||
|
|||||||
@@ -434,6 +434,34 @@ ccam lanes pipeline ship-feature # switch it
|
|||||||
|
|
||||||
or from the dashboard: the pipeline-template select next to the lane's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot).
|
or from the dashboard: the pipeline-template select next to the lane's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot).
|
||||||
|
|
||||||
|
**The template names the skill that drives it.** `"skill": "/ccam:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/ccam:ship-feature"` on the 8-node `default` one. It rides along on every lane payload as `pipeline_skill`, and from a terminal there is one command per depth of work — the command you type picks the pipeline, so no `ccam lanes pipeline` step first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/lanes/myrepo__feat-export
|
||||||
|
|
||||||
|
# 16 stages: ends in a published PR, watched until a human merges it
|
||||||
|
ccam ship-full "add an export button"
|
||||||
|
# → lane switched to `ship-feature`, then: claude "/ccam:ship-feature-lane add an export button"
|
||||||
|
|
||||||
|
# 8 stages: in-repo feature work, stops at review/gate — no PR, no browser QC
|
||||||
|
ccam ship-quick "fix the CSV separator"
|
||||||
|
# → lane switched to `default`, then: claude "/ccam:ship-feature fix the CSV separator"
|
||||||
|
|
||||||
|
ccam ship-full "add an export button" --print # show the command, switch nothing, run nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
| | `ccam ship-full` | `ccam ship-quick` |
|
||||||
|
|---|---|---|
|
||||||
|
| Template | `ship-feature` (16 nodes) | `default` (8 nodes) |
|
||||||
|
| Skill | `/ccam:ship-feature-lane` | `/ccam:ship-feature` |
|
||||||
|
| Ends at | PR open on `development`, watched until merged | `ship` in the working copy |
|
||||||
|
| Includes | unit-test agent, CI gates, e2e, code review, local browser QC, senior GO/NO-GO | plan, implement, tests, review, gate |
|
||||||
|
| Use when | the work should reach reviewers | the work stays local |
|
||||||
|
|
||||||
|
The lane is resolved from the working directory, so no id is needed (pass one to target another lane). Each command puts the lane on its own template *before* spawning, so the dashboard renders the right node count from the first stage rather than after the skill self-corrects; `--print` switches nothing. The requirement is handed to `claude` as ONE argv element — never through a shell — so quotes, `$` and `;` in it are text. A template with no `skill` field makes the command refuse rather than guess.
|
||||||
|
|
||||||
|
**The template also decides what the Workspace run console pre-fills.** A template may name the slash command that drives it — `"skill": "/ccam:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/ccam:ship-feature"` on the 8-node `default` one. That string rides along on every lane payload as `pipeline_skill`, and the run console starts its prompt box with it (plus a trailing space) so you only type the requirement: pick the 16-node template and Run starts the 16-step skill, pick the 8-node one and it starts the 8-step skill. The dashboard never executes it — it is text in a prompt box, exactly as if you had typed it. A prompt you have already edited is never overwritten by a later template switch, and a template with no `skill` field pre-fills nothing.
|
||||||
|
|
||||||
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `ccam stage` fixes it.
|
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `ccam stage` fixes it.
|
||||||
|
|
||||||
## Attaching a real terminal to a lane
|
## Attaching a real terminal to a lane
|
||||||
@@ -559,6 +587,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.ccam/pipelines/m
|
|||||||
{
|
{
|
||||||
"id": "ml-training",
|
"id": "ml-training",
|
||||||
"name": "ML Training Pipeline",
|
"name": "ML Training Pipeline",
|
||||||
|
"skill": "/my-training-skill",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{ "id": "data", "label": "data", "icon": "📊", "gate": false, "aliases": ["intake"] },
|
{ "id": "data", "label": "data", "icon": "📊", "gate": false, "aliases": ["intake"] },
|
||||||
{ "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "aliases": ["prepare", "clean"] },
|
{ "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "aliases": ["prepare", "clean"] },
|
||||||
@@ -573,6 +602,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.ccam/pipelines/m
|
|||||||
|
|
||||||
- **id** (string, required): unique identifier for this template
|
- **id** (string, required): unique identifier for this template
|
||||||
- **name** (string, optional): display name shown in the UI
|
- **name** (string, optional): display name shown in the UI
|
||||||
|
- **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/ccam:ship-feature-lane"`). The Workspace run console pre-fills its prompt with it for lanes on this template; omit it and the prompt starts empty. Never executed by the dashboard.
|
||||||
- **nodes** (array, required): list of pipeline stages, in order
|
- **nodes** (array, required): list of pipeline stages, in order
|
||||||
- **id** (string): stage identifier (used in `ccam stage`)
|
- **id** (string): stage identifier (used in `ccam stage`)
|
||||||
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
|
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
|
||||||
|
|||||||
@@ -542,3 +542,34 @@ describe("ccam lanes — inferred stage detection", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ccam ship-full / ccam ship-quick", () => {
|
||||||
|
it("ship-quick prints the 8-stage pipeline's skill, ship-full the 16-stage one", async () => {
|
||||||
|
const quick = await cli(["ship-quick", "add", "an", "export", "button", "--print"], LANE_DIR);
|
||||||
|
assert.equal(quick.status, 0, quick.stderr);
|
||||||
|
assert.match(quick.stdout, /claude "\/ccam:ship-feature add an export button"/);
|
||||||
|
|
||||||
|
const full = await cli(["ship-full", "add", "an", "export", "button", "--print"], LANE_DIR);
|
||||||
|
assert.equal(full.status, 0, full.stderr);
|
||||||
|
assert.match(full.stdout, /claude "\/ccam:ship-feature-lane add an export button"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("--print switches nothing: the lane keeps whatever template it was on", async () => {
|
||||||
|
const before = await get(`/api/lanes`);
|
||||||
|
const lane = before.body.lanes.find((l) => l.cwd === LANE_DIR);
|
||||||
|
const r = await cli(["ship-full", "anything", "--print"], LANE_DIR);
|
||||||
|
assert.equal(r.status, 0, r.stderr);
|
||||||
|
const after = await get(`/api/lanes`);
|
||||||
|
assert.equal(after.body.lanes.find((l) => l.id === lane.id).pipeline, lane.pipeline);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits non-zero with the command's own usage when no requirement is given", async () => {
|
||||||
|
const r = await cli(["ship-full"], LANE_DIR);
|
||||||
|
assert.notEqual(r.status, 0);
|
||||||
|
assert.match(r.stderr, /usage: ccam ship-full/);
|
||||||
|
|
||||||
|
const q = await cli(["ship-quick"], LANE_DIR);
|
||||||
|
assert.notEqual(q.status, 0);
|
||||||
|
assert.match(q.stderr, /usage: ccam ship-quick/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ describe("lanes lib", () => {
|
|||||||
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
|
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
|
||||||
assert.ok(p.progress > 0 && p.progress < 100);
|
assert.ok(p.progress > 0 && p.progress < 100);
|
||||||
assert.equal(p.liveness, "idle");
|
assert.equal(p.liveness, "idle");
|
||||||
|
// The template's driving slash command rides along so the run console can
|
||||||
|
// pre-fill its prompt with it.
|
||||||
|
assert.equal(p.pipeline_skill, "/ccam:ship-feature");
|
||||||
lanes.deleteLane(l.id);
|
lanes.deleteLane(l.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "default",
|
"id": "default",
|
||||||
"name": "Default feature pipeline",
|
"name": "Default feature pipeline",
|
||||||
|
"skill": "/ccam:ship-feature",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "intake",
|
"id": "intake",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "ship-feature",
|
"id": "ship-feature",
|
||||||
"name": "Ship feature (lane pipeline)",
|
"name": "Ship feature (lane pipeline)",
|
||||||
|
"skill": "/ccam:ship-feature-lane",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "intake",
|
"id": "intake",
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ function lanePayload(lane, ageSec = null) {
|
|||||||
return {
|
return {
|
||||||
...lane,
|
...lane,
|
||||||
pipeline_name: pipeline.name,
|
pipeline_name: pipeline.name,
|
||||||
|
pipeline_skill: pipeline.skill || "",
|
||||||
pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane),
|
pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane),
|
||||||
progress: progressPct(pipeline, lane),
|
progress: progressPct(pipeline, lane),
|
||||||
stage_seconds: Number.isNaN(since)
|
stage_seconds: Number.isNaN(since)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ function loadAll() {
|
|||||||
try {
|
try {
|
||||||
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
||||||
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
|
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
|
||||||
|
// The slash command that drives this pipeline. The Run console
|
||||||
|
// pre-fills its prompt with it, so a lane on the 16-node template
|
||||||
|
// starts the 16-step skill and one on the 8-node template the 8-step
|
||||||
|
// one. Never executed by the dashboard — it is text in a prompt box.
|
||||||
|
doc.skill = typeof doc.skill === "string" ? doc.skill : "";
|
||||||
doc.nodes = doc.nodes.map((n) => ({
|
doc.nodes = doc.nodes.map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
label: n.label || n.id,
|
label: n.label || n.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user