refactor(lanes): drop the workflow skills and Superpowers link; pipelines go dormant

The plugin stops shipping its workflow layer:
- skills/ship-feature and skills/ship-feature-lane, the 18 stage/ship slash
  commands (plugin.json keeps agenthub-doctor/update/open), and all four
  agents/ (qc-local, senior-gate-reviewer, backend-reviewer, frontend-reviewer).
- CLI `agenthub skills install`, `agenthub lanes agents install`,
  `agenthub ship-full|ship-quick`; routes POST /api/skills/install and
  POST /api/lanes/:id/agents/install; the Settings button, the lane Tools
  menu item and the Add-lane setup step that called them.
- The Superpowers auto-install: server/lib/skill-plugins.js, the bootstrap's
  autoInstallSkillPlugins / skillPluginsSettled gate and the doctor row.

Pipelines are kept but dormant: server/data/pipelines/ is deleted, so no
template ships. `default` resolves to EMPTY_PIPELINE (no nodes, progress 0),
createLane still accepts it, POST /api/lanes/:id/stage answers 400
ENOPIPELINE, and the Workspace hides the stage timer, pickers, PipelineMap
and strip stage chip for a lane with no nodes. A template dropped into
DASHBOARD_PIPELINES_DIR turns it all back on. The server suite loads its
templates from server/__tests__/fixtures/pipelines/.

Docs: CLAUDE.md (marker, retired rules), README(.vi), INSTALL, ARCHITECTURE,
docs/LANES|PLUGINS|CLI|API, agenthub-introduction.html.
This commit is contained in:
2026-09-28 17:44:27 +07:00
parent 3c238bc5c4
commit 2b8f83d2dd
72 changed files with 325 additions and 3305 deletions
+3 -66
View File
@@ -1,24 +1,11 @@
/**
* @file Installs a `claude` lane's driving instructions: the
* ship-feature-lane pipeline's agent templates
* (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
* driving session can launch them by the BARE subagent_type name. Source is
* the repo's own `agents/` — the same files the agenthub plugin ships, where
* they resolve as `agenthub:qc-local`; installing them into the lane is what
* makes the unprefixed name work there too. Plain file I/O except
* for one git call — resolving where a worktree lane's shared info/exclude
* actually lives, the same git-dir/git-common-dir distinction E2's
* lane-sync.js had to get right for info/attributes.
* @file Cleans up what older builds wrote into a lane: `removeStageDoc` takes
* AgentHub's stage block back out of a codex lane's AGENTS.md. Plain file I/O.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { git } = require("./worktree");
const TEMPLATE_DIR = path.join(__dirname, "..", "..", "agents");
const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
const EXCLUDE_LINE = ".claude/agents/";
// The block older builds wrote into a codex lane's AGENTS.md. Codex lanes no
// longer carry a pipeline, so `removeStageDoc` takes it back out.
@@ -52,54 +39,4 @@ function removeStageDoc(cwd) {
}
}
/** The dir shared across every worktree of a repo — where info/exclude
* lives (same as info/attributes; MERGE_HEAD/HEAD/the index are the only
* per-worktree-private state, not this). */
async function commonGitDir(cwd) {
const result = await git(cwd, ["rev-parse", "--git-common-dir"]);
const dir = result.stdout.trim();
return path.isAbsolute(dir) ? dir : path.join(cwd, dir);
}
/**
* Copy both agent templates into `<lane.cwd>/.claude/agents/` (overwriting
* any existing copy — this is a reinstall, not a merge) and idempotently
* git-exclude that directory in the shared common git dir.
*
* A `codex` or `antigravity` lane gets nothing: neither CLI reads
* `.claude/agents/`, and those lanes carry no pipeline to drive.
*
* @param {{cwd: string, tool?: string}} lane
* @returns {Promise<{installed: string[]}>}
*/
async function installAgents(lane) {
if (!fs.existsSync(path.join(lane.cwd, ".git"))) {
throw Object.assign(new Error(`lane has no .git — not a git repository: ${lane.cwd}`), {
code: "ENOTGITREPO",
});
}
if (lane.tool && lane.tool !== "claude") return { installed: [] };
const dest = path.join(lane.cwd, ".claude", "agents");
fs.mkdirSync(dest, { recursive: true });
const installed = [];
for (const name of TEMPLATE_FILES) {
fs.copyFileSync(path.join(TEMPLATE_DIR, name), path.join(dest, name));
installed.push(name);
}
const infoDir = path.join(await commonGitDir(lane.cwd), "info");
fs.mkdirSync(infoDir, { recursive: true });
const excludePath = path.join(infoDir, "exclude");
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
const lines = existing.split("\n").filter(Boolean);
if (!lines.includes(EXCLUDE_LINE)) {
lines.push(EXCLUDE_LINE);
fs.writeFileSync(excludePath, lines.join("\n") + "\n");
}
return { installed };
}
module.exports = { installAgents, removeStageDoc };
module.exports = { removeStageDoc };
+4 -1
View File
@@ -11,6 +11,7 @@
const { db } = require("../db");
const {
DEFAULT_PIPELINE_ID,
listPipelines,
getPipeline,
phaseIdx,
@@ -111,7 +112,9 @@ function validateTool(tool) {
* forever. Reject it at the write, where the caller can still be told.
*/
function validatePipeline(id) {
if (!listPipelines().some((p) => p.id === id)) {
// `default` is always valid: with no template installed it is the empty
// pipeline every lane sits on (pipelines.js), not a typo.
if (id !== DEFAULT_PIPELINE_ID && !listPipelines().some((p) => p.id === id)) {
throw Object.assign(new Error(`unknown pipeline: ${id}`), { code: "EBADPIPELINE" });
}
}
+18 -8
View File
@@ -1,10 +1,12 @@
/**
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
* (`server/data/pipelines/*.json` plus any override dropped in
* `DASHBOARD_PIPELINES_DIR`); this module resolves a lane's declared stage onto
* a node through per-node `aliases`, and derives the five render states the
* pipeline map draws. Pure functions — no DB, no I/O beyond the one-time
* template load, so it stays trivially testable.
* dropped in `DASHBOARD_PIPELINES_DIR` (`server/data/pipelines/` is read too,
* but AgentHub ships NO template any more — the pipeline feature is dormant
* until one is installed); this module resolves a lane's declared stage onto a
* node through per-node `aliases`, and derives the five render states the
* pipeline map draws. With no template, every lane is on an EMPTY `default`
* pipeline: no nodes, no states, progress 0. Pure functions — no DB, no I/O
* beyond the one-time template load, so it stays trivially testable.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,6 +16,14 @@ const path = require("node:path");
const DEFAULT_PIPELINE_ID = "default";
const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines");
/** What every lane is on while no template is installed. */
const EMPTY_PIPELINE = Object.freeze({
id: DEFAULT_PIPELINE_ID,
name: "",
skill: "",
nodes: Object.freeze([]),
});
/** Load every template once. A malformed file is skipped, never fatal. */
function loadAll() {
const dirs = [BUILTIN_DIR];
@@ -77,10 +87,10 @@ function listPipelines() {
return [...templates().values()];
}
/** Never throws: an unknown id yields the default template. */
/** Never throws: an unknown id yields the default template, else the empty one. */
function getPipeline(id) {
const t = templates();
return t.get(id) || t.get(DEFAULT_PIPELINE_ID);
return t.get(id) || t.get(DEFAULT_PIPELINE_ID) || EMPTY_PIPELINE;
}
/** Index of the node matching `stage` by id or alias; -1 when unknown. */
@@ -138,7 +148,7 @@ function nodeStates(pipeline, lane) {
function progressPct(pipeline, lane) {
const i = phaseIdx(pipeline, lane.stage);
if (i < 0) return 0;
if (i < 0 || pipeline.nodes.length < 2) return 0;
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
}
-239
View File
@@ -1,239 +0,0 @@
/**
* @file skill-plugins.js
* @description The plugins AgentHub's pipelines depend on: which ones a template
* names, which of those are actually resolvable, and the narrow set the
* SessionStart bootstrap is allowed to install on the user's behalf.
*
* Shared by `scripts/plugin-doctor.js` (reports) and
* `scripts/plugin-bootstrap.js` (installs) so the two can never disagree about
* what is missing. It lives here rather than in either script because
* plugin-doctor already requires plugin-bootstrap — the other direction would
* be a cycle.
*
* ## Reporting is derived, installing is allowlisted
* `requiredSkillPlugins()` reads the loaded pipeline templates, including any
* custom one from `DASHBOARD_PIPELINES_DIR`. That is right for a report: if a
* template names `/foo:bar`, the reader should be told `foo` is missing.
*
* It is NOT right for an install. A template is a file anyone can drop into
* that directory, so installing whatever plugin name it happens to contain
* would turn "edit a JSON file" into "run this vendor's plugin install command
* on the next session" — a supply-chain path AgentHub must not open. Auto-install
* therefore consults {@link AUTO_INSTALLABLE}, a fixed map in this file, and
* ignores every requirement outside it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { spawnSync } = require("node:child_process");
const { getClaudeHome } = require("./claude-home");
/**
* Plugins the bootstrap may install unattended, keyed by the plugin name a
* template refers to. `spec` is the exact `claude plugin install` argument;
* `marketplace` is the `owner/repo` to register first if that spec cannot be
* resolved because the marketplace is not known on this machine.
*
* Adding an entry here is a decision to install someone else's code on a
* user's machine without asking — it belongs in a review, not in a config file.
*
* @type {Record<string, {spec: string, marketplace: string}>}
*/
const AUTO_INSTALLABLE = {
superpowers: {
spec: "superpowers@claude-plugins-official",
marketplace: "anthropics/claude-plugins-official",
},
};
/** Slash commands are addressed `/plugin:skill`; a bare `/command` has no plugin. */
const SLASH_COMMAND_RE = /^\/([A-Za-z0-9_-]+):([A-Za-z0-9_-]+)/;
/**
* Every `/plugin:skill` the loaded pipeline templates name — as a pipeline's
* own `skill` or as one node's `command` — grouped by the plugin that has to
* provide it. Derived rather than hardcoded so editing a template cannot leave
* the check behind. `agenthub` is dropped: it is the plugin asking.
*
* @returns {Map<string, Set<string>>} plugin name → the skill names it must ship.
*/
function requiredSkillPlugins() {
const requirements = new Map();
let pipelines = [];
try {
pipelines = require("./pipelines").listPipelines();
} catch {
return requirements; // templates unreadable — a different failure, not this one
}
for (const pipeline of pipelines) {
for (const command of [pipeline.skill, ...pipeline.nodes.map((n) => n.command)]) {
const match = SLASH_COMMAND_RE.exec(command || "");
if (!match) continue; // a shell command, or a bare /command
const [, plugin, skill] = match;
if (plugin === "agenthub") continue;
if (!requirements.has(plugin)) requirements.set(plugin, new Set());
requirements.get(plugin).add(skill);
}
}
return requirements;
}
/**
* Which required plugins cannot be resolved right now.
*
* A plugin present but explicitly disabled counts as missing — Claude Code will
* not resolve its skills either way. A plugin whose skills all exist as plain
* user skills under `~/.claude/skills/` counts as satisfied: hand-copying them
* is a legitimate setup, and calling it broken is how a warning teaches its
* reader to ignore it.
*
* @returns {{name: string, skills: string[], autoInstall: {spec: string, marketplace: string}|null}[]}
* Empty when everything resolves. `null` autoInstall means report only.
* @throws When the installed-plugin manifest cannot be read at all.
*/
function missingSkillPlugins() {
const requirements = requiredSkillPlugins();
if (!requirements.size) return [];
const discovery = require("./cc-discovery");
const installed = discovery.readPlugins().plugins;
const userSkills = new Set(discovery.readSkills({ scope: "user" }).map((s) => s.name));
const missing = [];
for (const [name, skills] of requirements) {
const present = installed.find(
(p) => p.name === name && p.installPathExists && p.enabled !== false
);
if (present) continue;
const absent = [...skills].filter((s) => !userSkills.has(s)).sort();
if (!absent.length) continue;
missing.push({ name, skills: absent, autoInstall: AUTO_INSTALLABLE[name] || null });
}
return missing;
}
/**
* `claude plugin …`, synchronously. The bootstrap is a synchronous script, so
* it cannot use the promise-based `cc-plugin-cli.js` the HTTP routes go
* through; the argument validation that module carries is also moot here,
* because every argv element below comes from {@link AUTO_INSTALLABLE} — a
* constant in this file, never a request or a template.
*
* Still an argv array, never a shell string. `CLAUDE_CONFIG_DIR` is pinned
* only when the operator set `CLAUDE_HOME`, matching `cc-plugin-cli.js`'s
* `runClaude()`: left unset, the CLI's own defaults already write where
* `cc-discovery` reads, and pinning it unconditionally sent `.claude.json`
* to `~/.claude/.claude.json`, which nothing reads.
*
* @returns {{ok: boolean, output: string}}
*/
function runClaudePlugin(args, timeoutMs) {
const result = runClaudePluginOnce(args, timeoutMs);
// An older Claude Code rejects `-y` and never prompts; retry without it.
if (!result.ok && args.includes("-y") && /unknown option '-y'/.test(result.output)) {
return runClaudePluginOnce(
args.filter((a) => a !== "-y"),
timeoutMs
);
}
return result;
}
function runClaudePluginOnce(args, timeoutMs) {
const env = { ...process.env };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
const result = spawnSync("claude", args, {
timeout: timeoutMs,
encoding: "utf8",
maxBuffer: 1024 * 1024,
// Non-interactive: a would-be prompt must fail fast, not hang out the timeout.
stdio: ["ignore", "pipe", "pipe"],
env,
});
const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
if (result.error) {
const enoent = result.error.code === "ENOENT";
return {
ok: false,
output: enoent ? "the `claude` CLI is not on PATH" : String(result.error.message),
};
}
return { ok: result.status === 0, output };
}
/**
* Install the allowlisted plugins that are missing. Called from the bootstrap's
* DETACHED worker, so a slow clone never delays a session; its only channel is
* the caller's log.
*
* Best-effort by design: a failure is logged and the run moves on. The
* dashboard, hooks, CLI and MCP all work without these plugins — only the
* ship-feature pipelines need them — so nothing here may throw into the
* bootstrap.
*
* @param {(line: string) => void} log
* @param {{timeoutMs?: number, run?: typeof runClaudePlugin}} [options] `run` is
* a seam for tests: nothing else may reach a real `claude plugin install`.
* @returns {{attempted: string[], installed: string[], failed: string[], skipped: string[]}}
*/
function autoInstallSkillPlugins(log, { timeoutMs = 180_000, run = runClaudePlugin } = {}) {
const summary = { attempted: [], installed: [], failed: [], skipped: [] };
let missing = [];
try {
missing = missingSkillPlugins();
} catch {
return summary; // manifest unreadable — the doctor reports it, this stays quiet
}
for (const entry of missing) {
if (!entry.autoInstall) {
// Reported by /agenthub-doctor, never installed: not on the allowlist.
summary.skipped.push(entry.name);
continue;
}
const { spec, marketplace } = entry.autoInstall;
summary.attempted.push(entry.name);
log(`installing the ${entry.name} plugin — the pipelines drive ${entry.skills.join(", ")}`);
let result = run(["plugin", "install", spec, "--scope", "user", "-y"], timeoutMs);
if (!result.ok) {
// The most likely cause on a fresh machine is that the marketplace has
// never been registered, which `install` cannot do for us. Register it
// and retry exactly once.
const added = run(
["plugin", "marketplace", "add", marketplace, "--scope", "user"],
timeoutMs
);
if (added.ok) {
result = run(["plugin", "install", spec, "--scope", "user", "-y"], timeoutMs);
}
}
if (result.ok) {
summary.installed.push(entry.name);
log(
`installed ${spec} — run /reload-plugins to use it in THIS session ` +
`(a new session picks it up on its own)`
);
} else {
summary.failed.push(entry.name);
log(
`could not install ${spec}: ${result.output || "unknown error"} — ` +
`install it yourself with: claude plugin install ${spec}`
);
}
}
return summary;
}
module.exports = {
AUTO_INSTALLABLE,
requiredSkillPlugins,
missingSkillPlugins,
autoInstallSkillPlugins,
// Exported only so a test can pin the env it spawns with. Every other test
// injects `run` and so never reaches the real spawn — which is how an
// unconditional CLAUDE_CONFIG_DIR pin could be restored here unnoticed.
runClaudePlugin,
};
-36
View File
@@ -1,36 +0,0 @@
/**
* @file Installs skills/ship-feature-lane/ into ~/.claude/skills/, so
* /ship-feature-lane is discoverable from a session running inside any
* lane's own working directory. This is the FALLBACK path for a plain
* checkout: the skill also ships with the `agenthub` plugin (the repo root IS
* the plugin root, so `skills/` is the plugin's skills dir), where it is
* already global as `/agenthub:ship-feature-lane`. Installing it here just
* restores the bare `/ship-feature-lane` name for people who do not run the
* plugin. Shared by bin/agenthub.js's `agenthub skills install` CLI and the
* POST /api/skills/install route, so there is exactly one copy of this
* logic. Pure filesystem action.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/**
* @param {{repoRoot: string}} options - `repoRoot` is this checkout's own
* root (bin/agenthub.js already computes this as `REPO_ROOT`; the server
* computes its own equivalent — see the route for how).
* @returns {{installed: true, path: string}}
*/
function installShipFeatureLaneSkill({ repoRoot }) {
const src = path.join(repoRoot, "skills", "ship-feature-lane");
if (!fs.existsSync(src)) {
throw Object.assign(new Error(`not found: ${src}`), { code: "ENOSKILLSRC" });
}
const dest = path.join(os.homedir(), ".claude", "skills", "ship-feature-lane");
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true });
return { installed: true, path: dest };
}
module.exports = { installShipFeatureLaneSkill };