From 3efdc89d6af4bd88585b02d3b31001d42d86741a Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 15:39:09 +0800 Subject: [PATCH 01/11] feat: resolve recursive workflow skill trees --- docs/architecture.md | 17 + examples/workflows/skill-tree-demo/README.md | 10 + .../skills/skill-tree-demo/SKILL.md | 8 + .../workflows/skill-tree-demo/workflow.json | 27 + .../skill-tree-demo/workflow.lock.json | 74 ++ src/omniskill.ts | 45 +- src/runtimes/omniskill/workflow-bundles.ts | 635 ++++++++++++++++-- tests/omniskill.test.ts | 129 ++++ tests/workflow-bundles.test.ts | 537 +++++++++++++++ 9 files changed, 1407 insertions(+), 75 deletions(-) create mode 100644 examples/workflows/skill-tree-demo/README.md create mode 100644 examples/workflows/skill-tree-demo/skills/skill-tree-demo/SKILL.md create mode 100644 examples/workflows/skill-tree-demo/workflow.json create mode 100644 examples/workflows/skill-tree-demo/workflow.lock.json diff --git a/docs/architecture.md b/docs/architecture.md index 91ead22e..a40f4424 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,6 +64,9 @@ Compatibility aliases: - resolve local and public git bundle sources; bare `*-team` aliases route to `examples/teams/`, while other bare aliases route to `examples/workflows/` +- recursively resolve child workflows before installation, select the highest + declared semantic version for duplicate workflow names, deduplicate leaf + skills, and reject cycles with the full active workflow path - list skill dependency sources plus optional Skills CLI repository metadata - prepare looped workflow entry skill installs with copied `workflow.json`, generated `loop.mjs`, and generated `loop.metadata.json` @@ -145,6 +148,20 @@ skill sources so reviewers can see when a workflow's skill tree changed. Missing lock files remain valid for compatibility, but checked-in public workflows should include them. +Parents declare only direct dependencies. A local dependency directory that +contains `workflow.json` is a child workflow and is scanned recursively. Direct +repository URLs, including `#subdirectory` fragments, work the same way. +`catalog:` selects a canonical catalog workflow without making ordinary +bare skill names ambiguous, and `installed:` reopens an installed +workflow's recorded source. `skill-tree-demo` is the minimal local example. + +New locks use schema `0.2`: they store the selected workflow nodes and edges, +exact git commits, and fingerprints for the fully expanded leaf skill set. +Locked git children fetch their recorded commit directly on repeat installs. +Legacy `0.1` locks remain valid for flat workflows; once a child workflow is +detected, authors must regenerate the lock. Child workflows with the same name +must declare valid semantic versions, and the highest version's subtree wins. + Loop-enabled workflows declare `loop` in `workflow.json`, mark exactly one local skill with `entry: true`, and keep phase instructions in `steps[].instruction`. `loop.script` names the generated compatibility runner output path, usually diff --git a/examples/workflows/skill-tree-demo/README.md b/examples/workflows/skill-tree-demo/README.md new file mode 100644 index 00000000..622a9ebf --- /dev/null +++ b/examples/workflows/skill-tree-demo/README.md @@ -0,0 +1,10 @@ +# Skill Tree Demo + +This parent lists only its local entry skill and the direct `../cto` child +workflow. Omniskills scans the child workflow and installs its leaf skills. + +```bash +omniskill deps examples/workflows/skill-tree-demo +omniskill lock examples/workflows/skill-tree-demo +omniskill install examples/workflows/skill-tree-demo +``` diff --git a/examples/workflows/skill-tree-demo/skills/skill-tree-demo/SKILL.md b/examples/workflows/skill-tree-demo/skills/skill-tree-demo/SKILL.md new file mode 100644 index 00000000..cc5d015e --- /dev/null +++ b/examples/workflows/skill-tree-demo/skills/skill-tree-demo/SKILL.md @@ -0,0 +1,8 @@ +--- +name: skill-tree-demo +description: "Use when demonstrating recursive Omniskills workflow dependencies." +--- + +# Skill Tree Demo + +Coordinate the parent request, then use the installed CTO child workflow skills. diff --git a/examples/workflows/skill-tree-demo/workflow.json b/examples/workflows/skill-tree-demo/workflow.json new file mode 100644 index 00000000..891e1710 --- /dev/null +++ b/examples/workflows/skill-tree-demo/workflow.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "0.1", + "name": "skill-tree-demo", + "version": "0.1.0", + "description": "Demonstrates a parent that declares one direct child workflow.", + "skills": [ + { + "source": "./skills/skill-tree-demo", + "entry": true + }, + { + "source": "../cto" + } + ], + "steps": [ + { + "id": "coordinate", + "title": "Coordinate the skill tree", + "skill": "./skills/skill-tree-demo" + }, + { + "id": "architecture", + "title": "Run the CTO child workflow", + "skill": "../cto" + } + ] +} diff --git a/examples/workflows/skill-tree-demo/workflow.lock.json b/examples/workflows/skill-tree-demo/workflow.lock.json new file mode 100644 index 00000000..6242d11b --- /dev/null +++ b/examples/workflows/skill-tree-demo/workflow.lock.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": "0.2", + "workflow": "skill-tree-demo", + "workflowVersion": "0.1.0", + "generatedAt": "2026-07-14T07:36:42.069Z", + "workflows": [ + { + "id": "workflow:skill-tree-demo@0.1.0", + "name": "skill-tree-demo", + "version": "0.1.0", + "source": { + "kind": "local", + "path": "." + } + }, + { + "id": "workflow:cto@0.1.1", + "name": "cto", + "version": "0.1.1", + "source": { + "kind": "local", + "path": "../cto" + } + } + ], + "edges": [ + { + "from": "workflow:skill-tree-demo@0.1.0", + "to": "workflow:cto@0.1.1" + } + ], + "skills": [ + { + "source": "./skills/skill-tree-demo", + "resolvedName": "skill-tree-demo", + "kind": "local", + "hash": "sha256:473a43c02d642775b7210b749a592c84cd7df6fcfb2816ff08ddbe38d69f9173" + }, + { + "source": "../cto/skills/cto", + "resolvedName": "cto", + "kind": "local", + "hash": "sha256:537d4753b34622b969200709611d0751263e807a0500f02a1b35a5157b60320b" + }, + { + "source": "mattpocock:codebase-design", + "resolvedName": "codebase-design", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:452bfb044551a8177528b7df68e204e6a77b8a7f89daa741ef659f652c441376" + }, + { + "source": "mattpocock:domain-modeling", + "resolvedName": "domain-modeling", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:f01e41e3fdaf3c4d4c20ddea9eb00e8330003d91bb564ef4518aaee856d4bf69" + }, + { + "source": "mattpocock:diagnosing-bugs", + "resolvedName": "diagnosing-bugs", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:8148a4181f76442579999629563f08f31fa3784955584e781a190b64d65a1501" + }, + { + "source": "mattpocock:code-review", + "resolvedName": "code-review", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:913b01e36a6d3e28173b68e3147295754dd1ff2b91dbc23ebb1161cc687c91e6" + } + ] +} diff --git a/src/omniskill.ts b/src/omniskill.ts index b385ea9d..521cc914 100644 --- a/src/omniskill.ts +++ b/src/omniskill.ts @@ -30,6 +30,7 @@ import { installWorkflowBundle, listInstalledWorkflowBundles, loadWorkflowBundle, + resolveWorkflowDependencyGraph, type WorkflowGitCommandRunner, type WorkflowInstallSkillArtifact, type WorkflowRemovalPlan, @@ -293,7 +294,12 @@ function configureLockCommand(command: Command, options: ConfigureOmniskillComma ); } - const result = await writeWorkflowLockFile(bundle); + const result = await writeWorkflowLockFile(bundle, { + ...(options.workflowGitCommandRunner + ? { runGitCommand: options.workflowGitCommandRunner } + : {}), + installedRootDir: options.rootDir, + }); console.log(success(`Omniskills lock written: ${bundle.manifest.name}`)); console.log(keyValue("Lock file", result.path)); console.log(keyValue("Skills", String(result.lock.skills.length))); @@ -345,13 +351,23 @@ async function runOmniskillInstall( }); const installAgents = parseSkillInstallAgents(commandOptions.agents); const installedExternalPackages = new Set(); - const skillPlans = getWorkflowInstallSkillPlans(bundle); const installPrompt = options.installPrompt ?? createDefaultInstallPrompt(); let preparedDependencies: | Awaited> | undefined; try { + preparedDependencies = await getPreparedWorkflowSkillInstallDependencies({ + bundle, + ...(options.workflowGitCommandRunner + ? { runGitCommand: options.workflowGitCommandRunner } + : {}), + installedRootDir: targetDir, + }); + const skillPlans = preparedDependencies.dependencies.map((dependency, index) => ({ + source: preparedDependencies?.displaySources[index] ?? dependency.source, + ...(dependency.repo ? { repo: dependency.repo } : {}), + })); printOmniskillInstallPlan({ workflowName: bundle.manifest.name, workflowVersion: bundle.manifest.version, @@ -381,11 +397,10 @@ async function runOmniskillInstall( workflowName: bundle.manifest.name, workflowVersion: bundle.manifest.version, }); - preparedDependencies = await getPreparedWorkflowSkillInstallDependencies({ bundle }); const skillDependencies = preparedDependencies.dependencies; const installArtifacts: WorkflowInstallSkillArtifact[] = []; for (const [index, skillDependency] of skillDependencies.entries()) { - const manifestSource = skillPlans[index]?.source ?? skillDependency.source; + const manifestSource = preparedDependencies.displaySources[index] ?? skillDependency.source; const displaySkill = manifestSource; console.log(`Processing ${index + 1}/${skillDependencies.length}: ${displaySkill}`); const skillResult = await installOmniskillSkillDependency({ @@ -454,15 +469,6 @@ async function getWorkflowVersionRefreshSources(input: { return new Set((installedWorkflow.installArtifacts ?? []).map((artifact) => artifact.source)); } -function getWorkflowInstallSkillPlans(bundle: { - manifest: { skills: OmniskillInstallSkillPlan[] }; -}): OmniskillInstallSkillPlan[] { - return bundle.manifest.skills.map((skill) => ({ - source: skill.source, - ...(skill.repo ? { repo: skill.repo } : {}), - })); -} - function printOmniskillInstallPlan(input: { workflowName: string; workflowVersion: string; @@ -836,13 +842,20 @@ function configureDependencyCommand( ? { runGitCommand: options.workflowGitCommandRunner } : {}), }); + const graph = await resolveWorkflowDependencyGraph({ + bundle, + ...(options.workflowGitCommandRunner + ? { runGitCommand: options.workflowGitCommandRunner } + : {}), + installedRootDir: options.rootDir, + }); try { console.log(success(`Omniskills dependencies: ${bundle.manifest.name}`)); - for (const skill of bundle.manifest.skills) { - const optional = skill.optional ? " (optional)" : ""; - console.log(`- ${skill.source}${optional}`); + for (const [index, skill] of graph.dependencies.entries()) { + console.log(`- ${graph.displaySources[index] ?? skill.source}`); } } finally { + await graph.cleanup?.(); await bundle.cleanup?.(); } }); diff --git a/src/runtimes/omniskill/workflow-bundles.ts b/src/runtimes/omniskill/workflow-bundles.ts index 092c62e7..e892881e 100644 --- a/src/runtimes/omniskill/workflow-bundles.ts +++ b/src/runtimes/omniskill/workflow-bundles.ts @@ -232,7 +232,7 @@ const WorkflowSkillLockEntrySchema = z.object({ hash: z.string().regex(/^sha256:[a-f0-9]{64}$/), }); -export const WorkflowLockFileSchema = z.object({ +const LegacyWorkflowLockFileSchema = z.object({ schemaVersion: z.literal("0.1"), workflow: z.string().min(1), workflowVersion: z.string().min(1), @@ -240,6 +240,43 @@ export const WorkflowLockFileSchema = z.object({ skills: z.array(WorkflowSkillLockEntrySchema).min(1), }); +const LockedWorkflowSourceSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("local"), path: z.string().min(1) }), + z.object({ + kind: z.literal("git"), + url: z.string().min(1), + commit: z.string().min(1), + subdirectory: z.string().min(1).optional(), + }), +]); + +const WorkflowDependencyGraphWorkflowSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + version: z.string().min(1), + source: LockedWorkflowSourceSchema, +}); + +const WorkflowDependencyGraphEdgeSchema = z.object({ + from: z.string().min(1), + to: z.string().min(1), +}); + +const TransitiveWorkflowLockFileSchema = z.object({ + schemaVersion: z.literal("0.2"), + workflow: z.string().min(1), + workflowVersion: z.string().min(1), + generatedAt: z.string().datetime(), + workflows: z.array(WorkflowDependencyGraphWorkflowSchema).min(1), + edges: z.array(WorkflowDependencyGraphEdgeSchema), + skills: z.array(WorkflowSkillLockEntrySchema).min(1), +}); + +export const WorkflowLockFileSchema = z.union([ + LegacyWorkflowLockFileSchema, + TransitiveWorkflowLockFileSchema, +]); + export type WorkflowSkillLockEntry = z.infer; export type WorkflowLockFile = z.infer; @@ -294,6 +331,33 @@ export interface WorkflowSkillInstallDependency { repo?: string; } +export interface WorkflowDependencyResolver { + resolve(input: { + dependency: WorkflowSkillInstallDependency; + parent: WorkflowBundle; + }): Promise; +} + +export interface WorkflowDependencyGraphWorkflow { + id: string; + name: string; + version: string; + source: WorkflowBundleSource; +} + +export interface WorkflowDependencyGraphEdge { + from: string; + to: string; +} + +export interface WorkflowDependencyGraph { + dependencies: WorkflowSkillInstallDependency[]; + displaySources: string[]; + workflows: WorkflowDependencyGraphWorkflow[]; + edges: WorkflowDependencyGraphEdge[]; + cleanup?: () => Promise; +} + export interface WorkflowInstallSkillArtifact { source: string; skillName: string; @@ -344,6 +408,7 @@ export interface WorkflowLoopMetadata { export interface PreparedWorkflowSkillInstallDependencies { dependencies: WorkflowSkillInstallDependency[]; + displaySources: string[]; cleanup?: () => Promise; } @@ -363,12 +428,14 @@ export async function loadWorkflowBundle( cwd?: string; runGitCommand?: WorkflowGitCommandRunner; tempDir?: string; + commit?: string; } = {}, ): Promise { const resolvedSource = await resolveWorkflowBundleSource(source, { cwd: options.cwd ?? process.cwd(), ...(options.runGitCommand ? { runGitCommand: options.runGitCommand } : {}), ...(options.tempDir ? { tempDir: options.tempDir } : {}), + ...(options.commit ? { commit: options.commit } : {}), }); const manifestPath = resolvedSource.sourceDir.endsWith(workflowFileName) ? resolvedSource.sourceDir @@ -427,40 +494,46 @@ export async function loadWorkflowLockFile( export async function createWorkflowLockFile( bundle: WorkflowBundle, - options: { generatedAt?: Date | string } = {}, + options: { + generatedAt?: Date | string; + runGitCommand?: WorkflowGitCommandRunner; + tempDir?: string; + installedRootDir?: string; + } = {}, ): Promise { const generatedAt = typeof options.generatedAt === "string" ? options.generatedAt : (options.generatedAt ?? new Date()).toISOString(); - return WorkflowLockFileSchema.parse({ - schemaVersion: "0.1", - workflow: bundle.manifest.name, - workflowVersion: bundle.manifest.version, - generatedAt, - skills: await Promise.all( - bundle.manifest.skills.map(async (skill) => { - const isLocal = isLocalWorkflowSkillSource(skill.source); - const entry = { - source: skill.source, - resolvedName: getWorkflowLockResolvedName(skill.source), - kind: isLocal ? "local" : "external", - ...(skill.repo ? { repo: skill.repo } : {}), - hash: isLocal - ? await hashLocalWorkflowSkill(resolve(bundle.sourceDir, skill.source)) - : hashExternalWorkflowSkill(skill.source, skill.repo), - } satisfies WorkflowSkillLockEntry; - - return entry; - }), - ), + const graph = await resolveWorkflowDependencyGraph({ + bundle, + ignoreLockValidation: true, + ...(options.runGitCommand ? { runGitCommand: options.runGitCommand } : {}), + ...(options.tempDir ? { tempDir: options.tempDir } : {}), + ...(options.installedRootDir ? { installedRootDir: options.installedRootDir } : {}), }); + try { + return WorkflowLockFileSchema.parse({ + schemaVersion: "0.2", + workflow: bundle.manifest.name, + workflowVersion: bundle.manifest.version, + generatedAt, + workflows: graph.workflows.map((workflow) => ({ + ...workflow, + source: getPortableLockedWorkflowSource(bundle.sourceDir, workflow.source), + })), + edges: graph.edges, + skills: await createWorkflowLockSkillEntries(bundle.sourceDir, graph.dependencies), + }); + } finally { + await graph.cleanup?.(); + } } export async function writeWorkflowLockFile( bundle: WorkflowBundle, - options: { generatedAt?: Date | string } = {}, + options: Parameters[1] = {}, ): Promise<{ lock: WorkflowLockFile; path: string }> { const lock = await createWorkflowLockFile(bundle, options); const path = join(bundle.sourceDir, workflowLockFileName); @@ -635,6 +708,340 @@ export function getWorkflowSkillInstallDependencies( }); } +export async function resolveWorkflowDependencyGraph(input: { + bundle: WorkflowBundle; + resolver?: WorkflowDependencyResolver; + runGitCommand?: WorkflowGitCommandRunner; + tempDir?: string; + installedRootDir?: string; + ignoreLockValidation?: boolean; +}): Promise { + const resolver = + input.resolver ?? + createDefaultWorkflowDependencyResolver({ + ...(!input.ignoreLockValidation && input.bundle.lock ? { lock: input.bundle.lock } : {}), + ...(input.runGitCommand ? { runGitCommand: input.runGitCommand } : {}), + ...(input.tempDir ? { tempDir: input.tempDir } : {}), + ...(input.installedRootDir ? { installedRootDir: input.installedRootDir } : {}), + }); + const discovered = new Map(); + const resolvedChildren = new Map(); + const selectedByName = new Map(); + const cleanups: Array<() => Promise> = []; + + const discover = async (bundle: WorkflowBundle, active: WorkflowBundle[]): Promise => { + assertValidWorkflowSemver(bundle); + const canonicalId = getCanonicalWorkflowIdentity(bundle); + const cycleIndex = active.findIndex( + (candidate) => getCanonicalWorkflowIdentity(candidate) === canonicalId, + ); + if (cycleIndex >= 0) { + throw new Error( + `Workflow dependency cycle: ${[ + ...active.slice(cycleIndex).map(getDisplayWorkflowIdentity), + getDisplayWorkflowIdentity(bundle), + ].join(" -> ")}`, + ); + } + if (discovered.has(canonicalId)) { + return; + } + discovered.set(canonicalId, bundle); + + const selected = selectedByName.get(bundle.manifest.name); + if ( + !selected || + compareWorkflowSemver(bundle.manifest.version, selected.manifest.version) > 0 + ) { + selectedByName.set(bundle.manifest.name, bundle); + } + + const nextActive = [...active, bundle]; + for (const [index, dependency] of getWorkflowSkillInstallDependencies(bundle).entries()) { + const child = await resolver.resolve({ dependency, parent: bundle }); + if (!child) { + continue; + } + resolvedChildren.set(`${canonicalId}\n${index}`, child); + if (child.cleanup) { + cleanups.push(child.cleanup); + } + await discover(child, nextActive); + } + }; + + try { + await discover(input.bundle, []); + } catch (error) { + await Promise.allSettled(cleanups.map((cleanup) => cleanup())); + throw error; + } + + if ( + !input.ignoreLockValidation && + input.bundle.lock?.schemaVersion === "0.1" && + discovered.size > 1 + ) { + await Promise.allSettled(cleanups.map((cleanup) => cleanup())); + throw new Error( + "Legacy workflow lock 0.1 cannot represent nested workflows; run omniskill lock to regenerate workflow.lock.json", + ); + } + + const dependencies: WorkflowSkillInstallDependency[] = []; + const workflows: WorkflowDependencyGraphWorkflow[] = []; + const edges: WorkflowDependencyGraphEdge[] = []; + const seenDependencies = new Set(); + const seenWorkflows = new Set(); + const seenEdges = new Set(); + + const visit = async (bundle: WorkflowBundle, active: WorkflowBundle[]): Promise => { + const canonicalId = getCanonicalWorkflowIdentity(bundle); + const cycleIndex = active.findIndex( + (candidate) => getCanonicalWorkflowIdentity(candidate) === canonicalId, + ); + if (cycleIndex >= 0) { + throw new Error( + `Workflow dependency cycle: ${[ + ...active.slice(cycleIndex).map(getDisplayWorkflowIdentity), + getDisplayWorkflowIdentity(bundle), + ].join(" -> ")}`, + ); + } + if (seenWorkflows.has(canonicalId)) { + return; + } + seenWorkflows.add(canonicalId); + workflows.push({ + id: getDisplayWorkflowIdentity(bundle), + name: bundle.manifest.name, + version: bundle.manifest.version, + source: bundle.source, + }); + + const nextActive = [...active, bundle]; + for (const [index, dependency] of getWorkflowSkillInstallDependencies(bundle).entries()) { + const discoveredChild = resolvedChildren.get(`${canonicalId}\n${index}`); + const child = discoveredChild + ? (selectedByName.get(discoveredChild.manifest.name) ?? discoveredChild) + : null; + if (child) { + const edge = { + from: getDisplayWorkflowIdentity(bundle), + to: getDisplayWorkflowIdentity(child), + }; + const edgeId = `${edge.from}\n${edge.to}`; + if (!seenEdges.has(edgeId)) { + seenEdges.add(edgeId); + edges.push(edge); + } + await visit(child, nextActive); + continue; + } + + const dependencyId = `${dependency.source}\n${dependency.repo ?? ""}`; + if (!seenDependencies.has(dependencyId)) { + seenDependencies.add(dependencyId); + dependencies.push(dependency); + } + } + }; + + try { + await visit(input.bundle, []); + } catch (error) { + await Promise.allSettled(cleanups.map((cleanup) => cleanup())); + throw error; + } + + if (!input.ignoreLockValidation && input.bundle.lock?.schemaVersion === "0.2") { + try { + await validateResolvedTransitiveWorkflowLock({ + lock: input.bundle.lock, + rootDir: input.bundle.sourceDir, + workflows, + edges, + dependencies, + }); + } catch (error) { + await Promise.allSettled(cleanups.map((cleanup) => cleanup())); + throw error; + } + } + + return { + dependencies, + displaySources: + workflows.length === 1 + ? input.bundle.manifest.skills.map((skill) => skill.source) + : dependencies.map((dependency) => dependency.source), + workflows, + edges, + ...(cleanups.length > 0 + ? { cleanup: async () => Promise.all(cleanups.map((cleanup) => cleanup())).then(() => {}) } + : {}), + }; +} + +function assertValidWorkflowSemver(bundle: WorkflowBundle): void { + if (!parseWorkflowSemver(bundle.manifest.version)) { + throw new Error( + `Workflow ${bundle.manifest.name} declares invalid semantic version: ${bundle.manifest.version}`, + ); + } +} + +function compareWorkflowSemver(left: string, right: string): number { + const leftVersion = parseWorkflowSemver(left); + const rightVersion = parseWorkflowSemver(right); + if (!leftVersion || !rightVersion) { + throw new Error(`Cannot compare invalid workflow semantic versions: ${left}, ${right}`); + } + for (const index of [0, 1, 2] as const) { + const difference = leftVersion.core[index] - rightVersion.core[index]; + if (difference !== 0) { + return difference; + } + } + if (leftVersion.prerelease === rightVersion.prerelease) { + return 0; + } + if (!leftVersion.prerelease) { + return 1; + } + if (!rightVersion.prerelease) { + return -1; + } + return leftVersion.prerelease.localeCompare(rightVersion.prerelease, undefined, { + numeric: true, + }); +} + +function parseWorkflowSemver( + version: string, +): { core: [number, number, number]; prerelease: string } | null { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version, + ); + if (!match) { + return null; + } + const prerelease = match[4] ?? ""; + const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; + if ( + prereleaseIdentifiers.some( + (identifier) => + identifier.length === 0 || (/^\d+$/.test(identifier) && /^0\d+/.test(identifier)), + ) + ) { + return null; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease, + }; +} + +function createDefaultWorkflowDependencyResolver(options: { + lock?: WorkflowLockFile; + runGitCommand?: WorkflowGitCommandRunner; + tempDir?: string; + installedRootDir?: string; +}): WorkflowDependencyResolver { + return { + async resolve({ dependency, parent }) { + if (dependency.source.startsWith("catalog:")) { + const alias = dependency.source.slice("catalog:".length); + if (!alias) { + throw new Error("Catalog workflow dependency must include an alias"); + } + const lockedCommit = getLockedWorkflowCommit(options.lock, alias); + return loadWorkflowBundle(alias, { + cwd: parent.sourceDir, + ...(options.runGitCommand ? { runGitCommand: options.runGitCommand } : {}), + ...(options.tempDir ? { tempDir: options.tempDir } : {}), + ...(lockedCommit ? { commit: lockedCommit } : {}), + }); + } + if (dependency.source.startsWith("installed:")) { + const workflowName = dependency.source.slice("installed:".length); + if (!workflowName) { + throw new Error("Installed workflow dependency must include a workflow name"); + } + if (!options.installedRootDir) { + throw new Error( + `Installed workflow dependency requires installedRootDir: ${dependency.source}`, + ); + } + const installed = await loadInstalledWorkflowBundle({ + rootDir: options.installedRootDir, + workflowName, + }); + const recordedSource = installed.workflow.source; + return loadWorkflowBundle( + recordedSource.kind === "local" ? recordedSource.path : recordedSource.url, + { + cwd: parent.sourceDir, + ...(options.runGitCommand ? { runGitCommand: options.runGitCommand } : {}), + ...(options.tempDir ? { tempDir: options.tempDir } : {}), + ...(recordedSource.kind === "git" && recordedSource.commit + ? { commit: recordedSource.commit } + : {}), + }, + ); + } + if (parseGitWorkflowSource(dependency.source)) { + const lockedCommit = getLockedWorkflowCommit(options.lock, dependency.source); + return loadWorkflowBundle(dependency.source, { + cwd: parent.sourceDir, + ...(options.runGitCommand ? { runGitCommand: options.runGitCommand } : {}), + ...(options.tempDir ? { tempDir: options.tempDir } : {}), + ...(lockedCommit ? { commit: lockedCommit } : {}), + }); + } + const candidate = isAbsolute(dependency.source) + ? dependency.source + : resolve(parent.sourceDir, dependency.source); + const manifestPath = candidate.endsWith(workflowFileName) + ? candidate + : join(candidate, workflowFileName); + if (!existsSync(manifestPath)) { + return null; + } + return loadWorkflowBundle(candidate, { cwd: parent.sourceDir }); + }, + }; +} + +function getLockedWorkflowCommit( + lock: WorkflowLockFile | undefined, + source: string, +): string | null { + if (lock?.schemaVersion !== "0.2") { + return null; + } + const locked = lock.workflows.find( + (workflow) => + workflow.source.kind === "git" && + (workflow.source.url === source || workflow.name === source), + ); + return locked?.source.kind === "git" ? locked.source.commit : null; +} + +function getCanonicalWorkflowIdentity(bundle: WorkflowBundle): string { + if (bundle.source.kind === "local") { + return `local:${resolve(bundle.sourceDir)}`; + } + const url = new URL(bundle.source.url); + url.hash = ""; + return `git:${url.toString()}#${bundle.source.subdirectory ?? ""}@${bundle.source.commit ?? ""}`; +} + +function getDisplayWorkflowIdentity(bundle: WorkflowBundle): string { + return `workflow:${bundle.manifest.name}@${bundle.manifest.version}`; +} + export function createWorkflowLoopMetadata(bundle: WorkflowBundle): WorkflowLoopMetadata | null { if (!bundle.manifest.loop) { return null; @@ -663,10 +1070,22 @@ export function createWorkflowLoopMetadata(bundle: WorkflowBundle): WorkflowLoop export async function getPreparedWorkflowSkillInstallDependencies(input: { bundle: WorkflowBundle; tempDir?: string; + runGitCommand?: WorkflowGitCommandRunner; + installedRootDir?: string; }): Promise { - const dependencies = getWorkflowSkillInstallDependencies(input.bundle); + const graph = await resolveWorkflowDependencyGraph({ + bundle: input.bundle, + ...(input.runGitCommand ? { runGitCommand: input.runGitCommand } : {}), + ...(input.tempDir ? { tempDir: input.tempDir } : {}), + ...(input.installedRootDir ? { installedRootDir: input.installedRootDir } : {}), + }); + const dependencies = graph.dependencies; if (!input.bundle.manifest.loop) { - return { dependencies }; + return { + dependencies, + displaySources: graph.displaySources, + ...(graph.cleanup ? { cleanup: graph.cleanup } : {}), + }; } const entrySkill = getWorkflowEntrySkill(input.bundle.manifest); @@ -674,7 +1093,8 @@ export async function getPreparedWorkflowSkillInstallDependencies(input: { throw new Error("Looped workflow entry skill could not be resolved"); } - const entryIndex = input.bundle.manifest.skills.findIndex((skill) => skill.entry === true); + const entrySource = resolve(input.bundle.sourceDir, entrySkill.source); + const entryIndex = dependencies.findIndex((dependency) => dependency.source === entrySource); const sourceDependency = dependencies[entryIndex]; if (!sourceDependency) { throw new Error("Looped workflow entry dependency could not be resolved"); @@ -709,7 +1129,11 @@ export async function getPreparedWorkflowSkillInstallDependencies(input: { return { dependencies: preparedDependencies, - cleanup: () => rm(preparedRoot, { recursive: true, force: true }), + displaySources: graph.displaySources, + cleanup: async () => { + await rm(preparedRoot, { recursive: true, force: true }); + await graph.cleanup?.(); + }, }; } @@ -938,30 +1362,41 @@ function validateWorkflowLockFile(input: { `workflowVersion is ${input.lock.workflowVersion}, expected ${input.manifest.version}`, ); } - if (input.lock.skills.length !== input.manifest.skills.length) { - issues.push( - `skills length is ${input.lock.skills.length}, expected ${input.manifest.skills.length}`, - ); - } - - for (const [index, skill] of input.manifest.skills.entries()) { - const lockedSkill = input.lock.skills[index]; - if (!lockedSkill) { - continue; - } - if (lockedSkill.source !== skill.source) { - issues.push(`skills[${index}].source is ${lockedSkill.source}, expected ${skill.source}`); - } - if (lockedSkill.repo !== skill.repo) { + if (input.lock.schemaVersion === "0.1") { + if (input.lock.skills.length !== input.manifest.skills.length) { issues.push( - `skills[${index}].repo is ${lockedSkill.repo ?? ""}, expected ${ - skill.repo ?? "" - }`, + `skills length is ${input.lock.skills.length}, expected ${input.manifest.skills.length}`, ); } - const expectedKind = isLocalWorkflowSkillSource(skill.source) ? "local" : "external"; - if (lockedSkill.kind !== expectedKind) { - issues.push(`skills[${index}].kind is ${lockedSkill.kind}, expected ${expectedKind}`); + + for (const [index, skill] of input.manifest.skills.entries()) { + const lockedSkill = input.lock.skills[index]; + if (!lockedSkill) { + continue; + } + if (lockedSkill.source !== skill.source) { + issues.push(`skills[${index}].source is ${lockedSkill.source}, expected ${skill.source}`); + } + if (lockedSkill.repo !== skill.repo) { + issues.push( + `skills[${index}].repo is ${lockedSkill.repo ?? ""}, expected ${ + skill.repo ?? "" + }`, + ); + } + const expectedKind = isLocalWorkflowSkillSource(skill.source) ? "local" : "external"; + if (lockedSkill.kind !== expectedKind) { + issues.push(`skills[${index}].kind is ${lockedSkill.kind}, expected ${expectedKind}`); + } + } + } else { + const rootWorkflow = input.lock.workflows[0]; + if ( + !rootWorkflow || + rootWorkflow.name !== input.manifest.name || + rootWorkflow.version !== input.manifest.version + ) { + issues.push("transitive workflow graph does not start with the manifest workflow"); } } @@ -972,6 +1407,66 @@ function validateWorkflowLockFile(input: { } } +async function validateResolvedTransitiveWorkflowLock(input: { + lock: Extract; + rootDir: string; + workflows: WorkflowDependencyGraphWorkflow[]; + edges: WorkflowDependencyGraphEdge[]; + dependencies: WorkflowSkillInstallDependency[]; +}): Promise { + const resolvedWorkflows = input.workflows.map((workflow) => ({ + ...workflow, + source: getPortableLockedWorkflowSource(input.rootDir, workflow.source), + })); + const resolvedSkills = await createWorkflowLockSkillEntries(input.rootDir, input.dependencies); + const matches = + JSON.stringify(input.lock.workflows) === JSON.stringify(resolvedWorkflows) && + JSON.stringify(input.lock.edges) === JSON.stringify(input.edges) && + JSON.stringify(input.lock.skills) === JSON.stringify(resolvedSkills); + if (!matches) { + throw new Error("Transitive workflow lock does not match resolved dependency graph"); + } +} + +function getPortableLockedWorkflowSource( + rootDir: string, + source: WorkflowBundleSource, +): WorkflowBundleSource { + if (source.kind === "git") { + return source; + } + return { kind: "local", path: getPortableLocalLockSource(rootDir, source.path) }; +} + +async function createWorkflowLockSkillEntries( + rootDir: string, + dependencies: WorkflowSkillInstallDependency[], +): Promise { + return Promise.all( + dependencies.map(async (skill) => { + const isLocal = isAbsolute(skill.source); + const lockSource = isLocal ? getPortableLocalLockSource(rootDir, skill.source) : skill.source; + return { + source: lockSource, + resolvedName: getWorkflowLockResolvedName(lockSource), + kind: isLocal ? "local" : "external", + ...(skill.repo ? { repo: skill.repo } : {}), + hash: isLocal + ? await hashLocalWorkflowSkill(skill.source) + : hashExternalWorkflowSkill(skill.source, skill.repo), + } satisfies WorkflowSkillLockEntry; + }), + ); +} + +function getPortableLocalLockSource(rootDir: string, source: string): string { + const relativeSource = normalizeLockPath(relative(rootDir, source)); + if (!relativeSource) { + return "."; + } + return relativeSource.startsWith("..") ? relativeSource : `./${relativeSource}`; +} + function getWorkflowLockResolvedName(source: string): string { if (isLocalWorkflowSkillSource(source)) { return basename(source); @@ -1134,6 +1629,7 @@ async function resolveWorkflowBundleSource( cwd: string; runGitCommand?: WorkflowGitCommandRunner; tempDir?: string; + commit?: string; }, ): Promise { const aliasSource = parseWorkflowAliasSource(source); @@ -1251,6 +1747,7 @@ async function cloneGitWorkflowSource( cwd: string; runGitCommand?: WorkflowGitCommandRunner; tempDir?: string; + commit?: string; }, ): Promise { const tempRoot = await mkdtemp(join(options.tempDir ?? tmpdir(), "omniskill-git-")); @@ -1258,16 +1755,31 @@ async function cloneGitWorkflowSource( const cleanup = () => rm(tempRoot, { recursive: true, force: true }); try { - await runRequiredGitCommand( - { - executable: "git", - args: ["clone", "--depth", "1", source.cloneUrl, checkoutDir], - cwd: options.cwd, - env: process.env, - }, - options.runGitCommand, - `Public git workflow source could not be fetched: ${source.url}`, - ); + if (options.commit) { + for (const command of [ + { args: ["init", checkoutDir], cwd: options.cwd }, + { args: ["remote", "add", "origin", source.cloneUrl], cwd: checkoutDir }, + { args: ["fetch", "--depth", "1", "origin", options.commit], cwd: checkoutDir }, + { args: ["checkout", "--detach", options.commit], cwd: checkoutDir }, + ]) { + await runRequiredGitCommand( + { executable: "git", args: command.args, cwd: command.cwd, env: process.env }, + options.runGitCommand, + `Locked git workflow commit could not be fetched: ${source.url}@${options.commit}`, + ); + } + } else { + await runRequiredGitCommand( + { + executable: "git", + args: ["clone", "--depth", "1", source.cloneUrl, checkoutDir], + cwd: options.cwd, + env: process.env, + }, + options.runGitCommand, + `Public git workflow source could not be fetched: ${source.url}`, + ); + } const commitResult = await runOptionalGitCommand( { @@ -1279,6 +1791,11 @@ async function cloneGitWorkflowSource( options.runGitCommand, ); const commit = commitResult.exitCode === 0 ? commitResult.stdout.trim() : undefined; + if (options.commit && commit !== options.commit) { + throw new Error( + `Locked git workflow commit mismatch: expected ${options.commit}, resolved ${commit ?? ""}`, + ); + } const sourceDir = source.subdirectory ? join(checkoutDir, source.subdirectory) : checkoutDir; return { diff --git a/tests/omniskill.test.ts b/tests/omniskill.test.ts index 1a9d8db4..c1b588ca 100644 --- a/tests/omniskill.test.ts +++ b/tests/omniskill.test.ts @@ -300,6 +300,77 @@ describe("omniskill command module", () => { await rm(homeDir, { recursive: true, force: true }); }); + test("install resolves a nested repository before target writes", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "omniskill-nested-repository-")); + const homeDir = await mkdtemp(join(tmpdir(), "omniskill-nested-repository-home-")); + const parentDir = join(rootDir, "parent"); + const source = "file:///virtual/child.git"; + const events: string[] = []; + const program = new Command(); + await mkdir(parentDir, { recursive: true }); + await writeFile( + join(parentDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "parent", + version: "1.0.0", + description: "Parent workflow.", + skills: [{ source }], + steps: [{ id: "child", title: "Child", skill: source }], + }), + ); + + try { + configureOmniskillCommand(program, { + rootDir, + installPrompt: { confirmInstall: async () => true }, + workflowGitCommandRunner: async (command) => { + events.push(`git:${command.args[0]}`); + if (command.args[0] === "clone") { + const checkoutDir = command.args.at(-1) ?? ""; + await mkdir(join(checkoutDir, "skills", "child"), { recursive: true }); + await writeFile(join(checkoutDir, "skills", "child", "SKILL.md"), "# child\n"); + await writeFile( + join(checkoutDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "child", + version: "1.0.0", + description: "Child workflow.", + skills: [{ source: "./skills/child" }], + steps: [{ id: "run", title: "Run", skill: "./skills/child" }], + }), + ); + return { stdout: "", stderr: "", exitCode: 0 }; + } + return { stdout: "abc123\n", stderr: "", exitCode: 0 }; + }, + installSkill: async (input) => { + events.push(`install:${input.source}`); + return { + skillInstall: fakeSkillInstallResult({ + source: input.source, + skillName: "child", + destination: join(homeDir, ".agents", "skills", "child"), + }), + }; + }, + printSkillInstallResult: () => {}, + }); + + await program.parseAsync(["install", parentDir, "--home", homeDir, "--agents", "codex"], { + from: "user", + }); + + expect(events.slice(0, 2)).toEqual(["git:clone", "git:rev-parse"]); + expect(events[2]).toContain("install:"); + expect(events[2]).toContain("skills/child"); + } finally { + await rm(rootDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + } + }); + test("install writes the workflow record to the global home by default", async () => { const rootDir = await mkdtemp(join(tmpdir(), "omniskill-default-root-")); const homeDir = await mkdtemp(join(tmpdir(), "omniskill-default-home-")); @@ -1115,6 +1186,64 @@ describe("omniskill command module", () => { } }); + test("deps expands nested workflows into installable leaf skills", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "omniskill-nested-deps-")); + const parentDir = join(rootDir, "parent"); + const childDir = join(parentDir, "child"); + const logs: string[] = []; + const originalLog = console.log; + const program = new Command(); + const writeBundle = async (bundleDir: string, name: string, extraSource?: string) => { + await mkdir(join(bundleDir, "skills", name), { recursive: true }); + await writeFile(join(bundleDir, "skills", name, "SKILL.md"), `# ${name}\n`); + const sources = [`./skills/${name}`, ...(extraSource ? [extraSource] : [])]; + await writeFile( + join(bundleDir, "workflow.json"), + JSON.stringify( + { + schemaVersion: "0.1", + name, + version: "1.0.0", + description: `${name} workflow.`, + skills: sources.map((source) => ({ source })), + steps: sources.map((source, index) => ({ + id: `step-${index}`, + title: `Step ${index}`, + skill: source, + })), + }, + null, + 2, + ), + ); + }; + + console.log = (...values: unknown[]) => logs.push(values.join(" ")); + try { + await writeBundle(parentDir, "parent", "./child"); + await writeBundle(childDir, "child", "shared-review"); + configureOmniskillCommand(program, { + rootDir, + installSkill: async () => { + throw new Error("install is not exercised by this deps test"); + }, + printSkillInstallResult: () => {}, + }); + + await program.parseAsync(["deps", parentDir], { from: "user" }); + + expect(stripAnsiLines(logs)).toEqual([ + "Omniskills dependencies: parent", + `- ${join(parentDir, "skills", "parent")}`, + `- ${join(childDir, "skills", "child")}`, + "- shared-review", + ]); + } finally { + console.log = originalLog; + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("deps supports a workflow alias source", async () => { const rootDir = await mkdtemp(join(tmpdir(), "omniskill-alias-deps-")); const logs: string[] = []; diff --git a/tests/workflow-bundles.test.ts b/tests/workflow-bundles.test.ts index f9a54f0c..fbeca35b 100644 --- a/tests/workflow-bundles.test.ts +++ b/tests/workflow-bundles.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,9 +18,11 @@ import { listInstalledWorkflowBundles, loadWorkflowBundle, loadWorkflowLockFile, + resolveWorkflowDependencyGraph, WorkflowBundleManifestSchema, type WorkflowGitCommand, type WorkflowInstallSkillArtifact, + WorkflowLockFileSchema, workflowLockFileName, writeWorkflowLockFile, } from "../src/runtimes/omniskill/workflow-bundles"; @@ -121,6 +124,540 @@ describe("workflow bundles", () => { ); }); + test("creates a transitive lock while expanding three local workflow levels", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-dependency-tree-")); + const parentDir = join(rootDir, "parent"); + const childDir = join(parentDir, "child"); + const grandchildDir = join(childDir, "grandchild"); + + const writeBundle = async ( + bundleDir: string, + name: string, + skills: Array<{ source: string; repo?: string }>, + ) => { + await mkdir(join(bundleDir, "skills", name), { recursive: true }); + await writeFile(join(bundleDir, "skills", name, "SKILL.md"), `# ${name}\n`); + const directSkills = [{ source: `./skills/${name}` }, ...skills]; + await writeFile( + join(bundleDir, "workflow.json"), + `${JSON.stringify( + { + schemaVersion: "0.1", + name, + version: "1.0.0", + description: `${name} workflow.`, + skills: directSkills, + steps: directSkills.map((skill, index) => ({ + id: `step-${index}`, + title: `Step ${index}`, + skill: skill.source, + })), + }, + null, + 2, + )}\n`, + ); + }; + + await writeBundle(parentDir, "parent", [ + { source: "./child" }, + { source: "shared-review", repo: "example/shared@1.0.0" }, + ]); + await writeBundle(childDir, "child", [ + { source: "./grandchild" }, + { source: "shared-review", repo: "example/shared@1.0.0" }, + ]); + await writeBundle(grandchildDir, "grandchild", [ + { source: "shared-review", repo: "example/shared@1.0.0" }, + ]); + + try { + const bundle = await loadWorkflowBundle(parentDir); + const graph = await resolveWorkflowDependencyGraph({ bundle }); + + expect(graph.dependencies).toEqual([ + { source: join(parentDir, "skills", "parent") }, + { source: join(childDir, "skills", "child") }, + { source: join(grandchildDir, "skills", "grandchild") }, + { source: "shared-review", repo: "example/shared@1.0.0" }, + ]); + expect(graph.workflows.map((workflow) => workflow.name)).toEqual([ + "parent", + "child", + "grandchild", + ]); + expect(graph.edges).toEqual([ + { from: "workflow:parent@1.0.0", to: "workflow:child@1.0.0" }, + { from: "workflow:child@1.0.0", to: "workflow:grandchild@1.0.0" }, + ]); + const lock = await createWorkflowLockFile(bundle, { + generatedAt: "2026-07-14T00:00:00.000Z", + }); + expect(lock.schemaVersion).toBe("0.2"); + if (lock.schemaVersion !== "0.2") { + throw new Error("Expected a transitive workflow lock"); + } + expect(lock.workflows.map(({ name, version }) => `${name}@${version}`)).toEqual([ + "parent@1.0.0", + "child@1.0.0", + "grandchild@1.0.0", + ]); + expect(lock.edges).toEqual(graph.edges); + expect(lock.skills.map((skill) => skill.source)).toEqual([ + "./skills/parent", + "./child/skills/child", + "./child/grandchild/skills/grandchild", + "shared-review", + ]); + await graph.cleanup?.(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("rejects recursive workflow cycles with the full active path", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-dependency-cycle-")); + const firstDir = join(rootDir, "first"); + const secondDir = join(firstDir, "second"); + await mkdir(firstDir, { recursive: true }); + await mkdir(secondDir, { recursive: true }); + const manifest = (name: string, source: string) => ({ + schemaVersion: "0.1", + name, + version: "1.0.0", + description: `${name} workflow.`, + skills: [{ source }], + steps: [{ id: "run", title: "Run", skill: source }], + }); + await writeFile( + join(firstDir, "workflow.json"), + JSON.stringify(manifest("first", "./second"), null, 2), + ); + await writeFile( + join(secondDir, "workflow.json"), + JSON.stringify(manifest("second", ".."), null, 2), + ); + + try { + const bundle = await loadWorkflowBundle(firstDir); + await expect(resolveWorkflowDependencyGraph({ bundle })).rejects.toThrow( + "Workflow dependency cycle: workflow:first@1.0.0 -> workflow:second@1.0.0 -> workflow:first@1.0.0", + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("selects the highest child workflow semver and replaces the losing subtree", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-dependency-version-")); + const parentDir = join(rootDir, "parent"); + const lowDir = join(parentDir, "low"); + const highDir = join(parentDir, "high"); + const writeManifest = async ( + bundleDir: string, + name: string, + version: string, + sources: string[], + ) => { + await mkdir(bundleDir, { recursive: true }); + await writeFile( + join(bundleDir, "workflow.json"), + JSON.stringify( + { + schemaVersion: "0.1", + name, + version, + description: `${name} workflow.`, + skills: sources.map((source) => ({ source })), + steps: sources.map((source, index) => ({ + id: `step-${index}`, + title: `Step ${index}`, + skill: source, + })), + }, + null, + 2, + ), + ); + }; + await writeManifest(parentDir, "parent", "1.0.0", ["./low", "./high"]); + await writeManifest(lowDir, "shared-child", "1.2.0", ["low-only-skill"]); + await writeManifest(highDir, "shared-child", "1.10.0", ["high-only-skill"]); + + try { + const graph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(parentDir), + }); + + expect(graph.workflows.map(({ name, version }) => `${name}@${version}`)).toEqual([ + "parent@1.0.0", + "shared-child@1.10.0", + ]); + expect(graph.dependencies).toEqual([{ source: "high-only-skill" }]); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("expands a repository child workflow from a subdirectory and records its commit", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-repository-child-")); + const parentDir = join(rootDir, "parent"); + const source = "https://github.com/acme/workflows.git#packages/child"; + const commands: WorkflowGitCommand[] = []; + await mkdir(parentDir, { recursive: true }); + await writeFile( + join(parentDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "parent", + version: "1.0.0", + description: "Parent workflow.", + skills: [{ source }], + steps: [{ id: "child", title: "Child", skill: source }], + }), + ); + + try { + const graph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(parentDir), + runGitCommand: async (command) => { + commands.push(command); + if (command.args[0] === "clone") { + const checkoutDir = command.args.at(-1) ?? ""; + const childDir = join(checkoutDir, "packages", "child"); + await mkdir(join(childDir, "skills", "child"), { recursive: true }); + await writeFile(join(childDir, "skills", "child", "SKILL.md"), "# child\n"); + await writeFile( + join(childDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "child", + version: "2.0.0", + description: "Repository child.", + skills: [{ source: "./skills/child" }], + steps: [{ id: "run", title: "Run", skill: "./skills/child" }], + }), + ); + return { stdout: "", stderr: "", exitCode: 0 }; + } + return { stdout: "abc123\n", stderr: "", exitCode: 0 }; + }, + }); + + expect(commands.map((command) => command.args[0])).toEqual(["clone", "rev-parse"]); + expect(graph.workflows[1]).toMatchObject({ + name: "child", + version: "2.0.0", + source: { kind: "git", url: source, commit: "abc123", subdirectory: "packages/child" }, + }); + expect(graph.dependencies).toHaveLength(1); + expect(graph.dependencies[0]?.source).toContain("packages/child/skills/child"); + await graph.cleanup?.(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("expands catalog and installed child workflow sources", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-source-adapters-")); + const catalogParentDir = join(rootDir, "catalog-parent"); + const installedParentDir = join(rootDir, "installed-parent"); + const installedChildDir = join(rootDir, "installed-child"); + const writeManifest = async ( + bundleDir: string, + name: string, + version: string, + source: string, + ) => { + await mkdir(bundleDir, { recursive: true }); + await writeFile( + join(bundleDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name, + version, + description: `${name} workflow.`, + skills: [{ source }], + steps: [{ id: "run", title: "Run", skill: source }], + }), + ); + }; + await writeManifest(catalogParentDir, "catalog-parent", "1.0.0", "catalog:catalog-child"); + await writeManifest( + installedParentDir, + "installed-parent", + "1.0.0", + "installed:installed-child", + ); + await writeManifest(installedChildDir, "installed-child", "3.0.0", "installed-leaf"); + await installWorkflowBundle({ + rootDir, + bundle: await loadWorkflowBundle(installedChildDir), + }); + + try { + const catalogGraph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(catalogParentDir), + runGitCommand: async (command) => { + if (command.args[0] === "clone") { + const checkoutDir = command.args.at(-1) ?? ""; + await writeManifest( + join(checkoutDir, "examples", "workflows", "catalog-child"), + "catalog-child", + "2.0.0", + "catalog-leaf", + ); + return { stdout: "", stderr: "", exitCode: 0 }; + } + return { stdout: "catalog-commit\n", stderr: "", exitCode: 0 }; + }, + }); + const installedGraph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(installedParentDir), + installedRootDir: rootDir, + }); + const installedLock = await createWorkflowLockFile( + await loadWorkflowBundle(installedParentDir), + { installedRootDir: rootDir, generatedAt: "2026-07-14T00:00:00.000Z" }, + ); + + expect(catalogGraph.dependencies).toEqual([{ source: "catalog-leaf" }]); + expect(installedGraph.dependencies).toEqual([{ source: "installed-leaf" }]); + expect(catalogGraph.workflows.map((workflow) => workflow.name)).toEqual([ + "catalog-parent", + "catalog-child", + ]); + expect(installedGraph.workflows.map((workflow) => workflow.name)).toEqual([ + "installed-parent", + "installed-child", + ]); + expect(installedLock.schemaVersion).toBe("0.2"); + if (installedLock.schemaVersion === "0.2") { + expect(installedLock.workflows.map((workflow) => workflow.name)).toEqual([ + "installed-parent", + "installed-child", + ]); + } + await catalogGraph.cleanup?.(); + await installedGraph.cleanup?.(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("reuses a locked git child commit without default-branch lookup", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-locked-child-")); + const parentDir = join(rootDir, "parent"); + const source = "https://github.com/acme/child.git"; + const lockedCommit = "0123456789abcdef"; + const commands: WorkflowGitCommand[] = []; + await mkdir(parentDir, { recursive: true }); + await writeFile( + join(parentDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "parent", + version: "1.0.0", + description: "Parent workflow.", + skills: [{ source }], + steps: [{ id: "child", title: "Child", skill: source }], + }), + ); + + try { + const bundle = await loadWorkflowBundle(parentDir); + const leafHash = createHash("sha256") + .update("omniskill-workflow-lock-external-skill-v0.1\n") + .update("source:child-leaf\n") + .update("repo:\n") + .digest("hex"); + bundle.lock = WorkflowLockFileSchema.parse({ + schemaVersion: "0.2", + workflow: "parent", + workflowVersion: "1.0.0", + generatedAt: "2026-07-14T00:00:00.000Z", + workflows: [ + { + id: "workflow:parent@1.0.0", + name: "parent", + version: "1.0.0", + source: { kind: "local", path: "." }, + }, + { + id: "workflow:child@2.0.0", + name: "child", + version: "2.0.0", + source: { kind: "git", url: source, commit: lockedCommit }, + }, + ], + edges: [{ from: "workflow:parent@1.0.0", to: "workflow:child@2.0.0" }], + skills: [ + { + source: "child-leaf", + resolvedName: "child-leaf", + kind: "external", + hash: `sha256:${leafHash}`, + }, + ], + }); + + const graph = await resolveWorkflowDependencyGraph({ + bundle, + runGitCommand: async (command) => { + commands.push(command); + if (command.args[0] === "init") { + const checkoutDir = command.args.at(-1) ?? ""; + await mkdir(checkoutDir, { recursive: true }); + await writeFile( + join(checkoutDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "child", + version: "2.0.0", + description: "Locked child.", + skills: [{ source: "child-leaf" }], + steps: [{ id: "run", title: "Run", skill: "child-leaf" }], + }), + ); + } + return { + stdout: command.args[0] === "rev-parse" ? `${lockedCommit}\n` : "", + stderr: "", + exitCode: 0, + }; + }, + }); + + expect(commands.map((command) => command.args[0])).toEqual([ + "init", + "remote", + "fetch", + "checkout", + "rev-parse", + ]); + expect(commands.find((command) => command.args[0] === "fetch")?.args).toContain(lockedCommit); + expect(graph.workflows[1]?.source).toMatchObject({ commit: lockedCommit }); + await graph.cleanup?.(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("rejects a legacy lock for a nested workflow with a regeneration error", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-legacy-nested-lock-")); + const parentDir = join(rootDir, "parent"); + const childDir = join(parentDir, "child"); + await mkdir(childDir, { recursive: true }); + const manifest = (name: string, source: string) => ({ + schemaVersion: "0.1", + name, + version: "1.0.0", + description: `${name} workflow.`, + skills: [{ source }], + steps: [{ id: "run", title: "Run", skill: source }], + }); + await writeFile( + join(parentDir, "workflow.json"), + JSON.stringify(manifest("parent", "./child")), + ); + await writeFile( + join(childDir, "workflow.json"), + JSON.stringify(manifest("child", "child-leaf")), + ); + await writeFile( + join(parentDir, workflowLockFileName), + JSON.stringify({ + schemaVersion: "0.1", + workflow: "parent", + workflowVersion: "1.0.0", + generatedAt: "2026-07-14T00:00:00.000Z", + skills: [ + { + source: "./child", + resolvedName: "child", + kind: "local", + hash: `sha256:${"a".repeat(64)}`, + }, + ], + }), + ); + + try { + const bundle = await loadWorkflowBundle(parentDir); + await expect(resolveWorkflowDependencyGraph({ bundle })).rejects.toThrow( + "Legacy workflow lock 0.1 cannot represent nested workflows; run omniskill lock", + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("rejects a stale transitive lock leaf fingerprint", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-stale-transitive-lock-")); + const bundleDir = join(rootDir, "locked"); + await mkdir(join(bundleDir, "skills", "locked"), { recursive: true }); + await writeFile(join(bundleDir, "skills", "locked", "SKILL.md"), "# locked\n"); + await writeFile( + join(bundleDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "locked", + version: "1.0.0", + description: "Locked workflow.", + skills: [{ source: "./skills/locked" }], + steps: [{ id: "run", title: "Run", skill: "./skills/locked" }], + }), + ); + + try { + const bundle = await loadWorkflowBundle(bundleDir); + const lock = await createWorkflowLockFile(bundle, { + generatedAt: "2026-07-14T00:00:00.000Z", + }); + if (lock.schemaVersion !== "0.2") { + throw new Error("Expected a transitive lock"); + } + const lockedSkill = lock.skills[0]; + if (!lockedSkill) { + throw new Error("Expected a locked skill"); + } + lock.skills[0] = { ...lockedSkill, hash: `sha256:${"f".repeat(64)}` }; + bundle.lock = lock; + + await expect(resolveWorkflowDependencyGraph({ bundle })).rejects.toThrow( + "Transitive workflow lock does not match resolved dependency graph", + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("rejects invalid semantic prerelease identifiers", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-invalid-semver-")); + try { + for (const version of ["1.0.0-alpha..1", "1.0.0-alpha.01"]) { + const bundleDir = join(rootDir, version.replaceAll(".", "-")); + await mkdir(bundleDir, { recursive: true }); + await writeFile( + join(bundleDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "invalid-version", + version, + description: "Invalid version workflow.", + skills: [{ source: "leaf" }], + steps: [{ id: "run", title: "Run", skill: "leaf" }], + }), + ); + await expect( + resolveWorkflowDependencyGraph({ bundle: await loadWorkflowBundle(bundleDir) }), + ).rejects.toThrow(`declares invalid semantic version: ${version}`); + } + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("parses first-class team metadata while legacy manifests remain workflows", () => { const team = WorkflowBundleManifestSchema.parse(validTeamManifest); const legacy = WorkflowBundleManifestSchema.parse({ From 43dfac2bafeb426b106b5e88b4159ccc56e723c7 Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 15:54:40 +0800 Subject: [PATCH 02/11] fix: harden recursive workflow resolution --- .../skill-tree-demo/workflow.lock.json | 5 +- src/runtimes/omniskill/workflow-bundles.ts | 110 +++++++++++++++--- tests/workflow-bundles.test.ts | 58 ++++++++- 3 files changed, 148 insertions(+), 25 deletions(-) diff --git a/examples/workflows/skill-tree-demo/workflow.lock.json b/examples/workflows/skill-tree-demo/workflow.lock.json index 6242d11b..caebe9aa 100644 --- a/examples/workflows/skill-tree-demo/workflow.lock.json +++ b/examples/workflows/skill-tree-demo/workflow.lock.json @@ -2,15 +2,14 @@ "schemaVersion": "0.2", "workflow": "skill-tree-demo", "workflowVersion": "0.1.0", - "generatedAt": "2026-07-14T07:36:42.069Z", + "generatedAt": "2026-07-14T07:48:54.368Z", "workflows": [ { "id": "workflow:skill-tree-demo@0.1.0", "name": "skill-tree-demo", "version": "0.1.0", "source": { - "kind": "local", - "path": "." + "kind": "root" } }, { diff --git a/src/runtimes/omniskill/workflow-bundles.ts b/src/runtimes/omniskill/workflow-bundles.ts index e892881e..df2b645e 100644 --- a/src/runtimes/omniskill/workflow-bundles.ts +++ b/src/runtimes/omniskill/workflow-bundles.ts @@ -61,6 +61,14 @@ export const WorkflowBundleManifestSchema = z steps: z.array(WorkflowStepSchema).min(1), }) .superRefine((manifest, context) => { + if (!parseWorkflowSemver(manifest.version)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid workflow semantic version: ${manifest.version}`, + path: ["version"], + }); + } + const stepIds = new Set(); for (const [index, step] of manifest.steps.entries()) { if (stepIds.has(step.id)) { @@ -241,6 +249,7 @@ const LegacyWorkflowLockFileSchema = z.object({ }); const LockedWorkflowSourceSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("root") }), z.object({ kind: z.literal("local"), path: z.string().min(1) }), z.object({ kind: z.literal("git"), @@ -519,10 +528,7 @@ export async function createWorkflowLockFile( workflow: bundle.manifest.name, workflowVersion: bundle.manifest.version, generatedAt, - workflows: graph.workflows.map((workflow) => ({ - ...workflow, - source: getPortableLockedWorkflowSource(bundle.sourceDir, workflow.source), - })), + workflows: createLockedWorkflowGraph(bundle.sourceDir, graph.workflows), edges: graph.edges, skills: await createWorkflowLockSkillEntries(bundle.sourceDir, graph.dependencies), }); @@ -788,10 +794,10 @@ export async function resolveWorkflowDependencyGraph(input: { ); } - const dependencies: WorkflowSkillInstallDependency[] = []; + const selectedDependencies = new Map(); + const dependencyOrder: string[] = []; const workflows: WorkflowDependencyGraphWorkflow[] = []; const edges: WorkflowDependencyGraphEdge[] = []; - const seenDependencies = new Set(); const seenWorkflows = new Set(); const seenEdges = new Set(); @@ -839,10 +845,13 @@ export async function resolveWorkflowDependencyGraph(input: { continue; } - const dependencyId = `${dependency.source}\n${dependency.repo ?? ""}`; - if (!seenDependencies.has(dependencyId)) { - seenDependencies.add(dependencyId); - dependencies.push(dependency); + const dependencyId = dependency.source; + const selectedDependency = selectedDependencies.get(dependencyId); + if (!selectedDependency) { + dependencyOrder.push(dependencyId); + selectedDependencies.set(dependencyId, dependency); + } else if (isPreferredWorkflowSkillDependency(dependency, selectedDependency)) { + selectedDependencies.set(dependencyId, dependency); } } }; @@ -854,6 +863,11 @@ export async function resolveWorkflowDependencyGraph(input: { throw error; } + const dependencies = dependencyOrder.flatMap((dependencyId) => { + const dependency = selectedDependencies.get(dependencyId); + return dependency ? [dependency] : []; + }); + if (!input.ignoreLockValidation && input.bundle.lock?.schemaVersion === "0.2") { try { await validateResolvedTransitiveWorkflowLock({ @@ -871,10 +885,7 @@ export async function resolveWorkflowDependencyGraph(input: { return { dependencies, - displaySources: - workflows.length === 1 - ? input.bundle.manifest.skills.map((skill) => skill.source) - : dependencies.map((dependency) => dependency.source), + displaySources: getWorkflowDependencyDisplaySources(input.bundle, workflows, dependencies), workflows, edges, ...(cleanups.length > 0 @@ -883,6 +894,50 @@ export async function resolveWorkflowDependencyGraph(input: { }; } +function isPreferredWorkflowSkillDependency( + candidate: WorkflowSkillInstallDependency, + selected: WorkflowSkillInstallDependency, +): boolean { + const candidateVersion = getWorkflowSkillDependencySemver(candidate.repo); + const selectedVersion = getWorkflowSkillDependencySemver(selected.repo); + if (!candidateVersion) { + return selectedVersion !== null; + } + if (!selectedVersion) { + return false; + } + return compareWorkflowSemver(candidateVersion, selectedVersion) > 0; +} + +function getWorkflowSkillDependencySemver(repo: string | undefined): string | null { + if (!repo) { + return null; + } + const separatorIndex = repo.lastIndexOf("@"); + if (separatorIndex <= 0 || separatorIndex === repo.length - 1) { + return null; + } + const candidate = repo.slice(separatorIndex + 1); + return parseWorkflowSemver(candidate) ? candidate : null; +} + +function getWorkflowDependencyDisplaySources( + root: WorkflowBundle, + workflows: WorkflowDependencyGraphWorkflow[], + dependencies: WorkflowSkillInstallDependency[], +): string[] { + if (workflows.length > 1) { + return dependencies.map((dependency) => dependency.source); + } + const rootDependencies = getWorkflowSkillInstallDependencies(root); + return dependencies.map((dependency) => { + const index = rootDependencies.findIndex( + (candidate) => candidate.source === dependency.source && candidate.repo === dependency.repo, + ); + return root.manifest.skills[index]?.source ?? dependency.source; + }); +} + function assertValidWorkflowSemver(bundle: WorkflowBundle): void { if (!parseWorkflowSemver(bundle.manifest.version)) { throw new Error( @@ -1414,13 +1469,14 @@ async function validateResolvedTransitiveWorkflowLock(input: { edges: WorkflowDependencyGraphEdge[]; dependencies: WorkflowSkillInstallDependency[]; }): Promise { - const resolvedWorkflows = input.workflows.map((workflow) => ({ + const resolvedWorkflows = createLockedWorkflowGraph(input.rootDir, input.workflows); + const lockedWorkflows = input.lock.workflows.map((workflow, index) => ({ ...workflow, - source: getPortableLockedWorkflowSource(input.rootDir, workflow.source), + source: index === 0 ? { kind: "root" as const } : workflow.source, })); const resolvedSkills = await createWorkflowLockSkillEntries(input.rootDir, input.dependencies); const matches = - JSON.stringify(input.lock.workflows) === JSON.stringify(resolvedWorkflows) && + JSON.stringify(lockedWorkflows) === JSON.stringify(resolvedWorkflows) && JSON.stringify(input.lock.edges) === JSON.stringify(input.edges) && JSON.stringify(input.lock.skills) === JSON.stringify(resolvedSkills); if (!matches) { @@ -1428,12 +1484,28 @@ async function validateResolvedTransitiveWorkflowLock(input: { } } +function createLockedWorkflowGraph( + rootDir: string, + workflows: WorkflowDependencyGraphWorkflow[], +): Array> { + return workflows.map((workflow, index) => ({ + ...workflow, + source: + index === 0 + ? { kind: "root" as const } + : getPortableLockedWorkflowSource(rootDir, workflow.source), + })); +} + function getPortableLockedWorkflowSource( rootDir: string, source: WorkflowBundleSource, -): WorkflowBundleSource { +): z.infer { if (source.kind === "git") { - return source; + if (!source.commit) { + throw new Error(`Locked child workflow git source is missing an exact commit: ${source.url}`); + } + return { ...source, commit: source.commit }; } return { kind: "local", path: getPortableLocalLockSource(rootDir, source.path) }; } diff --git a/tests/workflow-bundles.test.ts b/tests/workflow-bundles.test.ts index fbeca35b..6aacd14e 100644 --- a/tests/workflow-bundles.test.ts +++ b/tests/workflow-bundles.test.ts @@ -124,6 +124,58 @@ describe("workflow bundles", () => { ); }); + test("validates a transitive lock with transport-independent root identity", async () => { + const repositoryUrl = pathToFileURL(join(import.meta.dir, "..")).toString(); + const bundle = await loadWorkflowBundle(`${repositoryUrl}#examples/workflows/skill-tree-demo`); + + try { + const graph = await resolveWorkflowDependencyGraph({ bundle }); + expect(graph.workflows.map((workflow) => workflow.name)).toEqual(["skill-tree-demo", "cto"]); + } finally { + await bundle.cleanup?.(); + } + }); + + test("selects the highest leaf repository semver for one logical skill", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-leaf-version-")); + await writeFile( + join(rootDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "leaf-version", + version: "1.0.0", + description: "Leaf version selection.", + skills: [ + { source: "shared", repo: "org/pkg@1.0.0" }, + { source: "shared", repo: "org/pkg@2.0.0" }, + ], + steps: [{ id: "run", title: "Run", skill: "shared" }], + }), + ); + + try { + const graph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(rootDir), + }); + expect(graph.dependencies).toEqual([{ source: "shared", repo: "org/pkg@2.0.0" }]); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("rejects invalid manifest semantic versions during schema parsing", () => { + expect(() => + WorkflowBundleManifestSchema.parse({ + schemaVersion: "0.1", + name: "invalid-version", + version: "not-semver", + description: "Invalid version.", + skills: [{ source: "leaf" }], + steps: [{ id: "run", title: "Run", skill: "leaf" }], + }), + ).toThrow("Invalid workflow semantic version: not-semver"); + }); + test("creates a transitive lock while expanding three local workflow levels", async () => { const rootDir = await mkdtemp(join(tmpdir(), "workflow-dependency-tree-")); const parentDir = join(rootDir, "parent"); @@ -649,9 +701,9 @@ describe("workflow bundles", () => { steps: [{ id: "run", title: "Run", skill: "leaf" }], }), ); - await expect( - resolveWorkflowDependencyGraph({ bundle: await loadWorkflowBundle(bundleDir) }), - ).rejects.toThrow(`declares invalid semantic version: ${version}`); + await expect(loadWorkflowBundle(bundleDir)).rejects.toThrow( + `Invalid workflow semantic version: ${version}`, + ); } } finally { await rm(rootDir, { recursive: true, force: true }); From dd8e9aae9bad55e7d94323af04e49efcf0b8ce9e Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 16:04:33 +0800 Subject: [PATCH 03/11] fix: enforce workflow lock precedence --- src/runtimes/omniskill/workflow-bundles.ts | 15 ++++-- tests/workflow-bundles.test.ts | 57 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/runtimes/omniskill/workflow-bundles.ts b/src/runtimes/omniskill/workflow-bundles.ts index df2b645e..e016d10b 100644 --- a/src/runtimes/omniskill/workflow-bundles.ts +++ b/src/runtimes/omniskill/workflow-bundles.ts @@ -901,10 +901,10 @@ function isPreferredWorkflowSkillDependency( const candidateVersion = getWorkflowSkillDependencySemver(candidate.repo); const selectedVersion = getWorkflowSkillDependencySemver(selected.repo); if (!candidateVersion) { - return selectedVersion !== null; + return false; } if (!selectedVersion) { - return false; + return true; } return compareWorkflowSemver(candidateVersion, selectedVersion) > 0; } @@ -1472,7 +1472,7 @@ async function validateResolvedTransitiveWorkflowLock(input: { const resolvedWorkflows = createLockedWorkflowGraph(input.rootDir, input.workflows); const lockedWorkflows = input.lock.workflows.map((workflow, index) => ({ ...workflow, - source: index === 0 ? { kind: "root" as const } : workflow.source, + source: index === 0 ? validateLockedRootSource(workflow.source) : workflow.source, })); const resolvedSkills = await createWorkflowLockSkillEntries(input.rootDir, input.dependencies); const matches = @@ -1484,6 +1484,15 @@ async function validateResolvedTransitiveWorkflowLock(input: { } } +function validateLockedRootSource(source: z.infer): { + kind: "root"; +} { + if (source.kind === "root" || (source.kind === "local" && source.path === ".")) { + return { kind: "root" }; + } + throw new Error("Transitive workflow lock has an invalid root source"); +} + function createLockedWorkflowGraph( rootDir: string, workflows: WorkflowDependencyGraphWorkflow[], diff --git a/tests/workflow-bundles.test.ts b/tests/workflow-bundles.test.ts index 6aacd14e..9f6909bb 100644 --- a/tests/workflow-bundles.test.ts +++ b/tests/workflow-bundles.test.ts @@ -136,6 +136,31 @@ describe("workflow bundles", () => { } }); + test("rejects a tampered root source in a transitive lock", async () => { + const bundle = await loadWorkflowBundle( + join(import.meta.dir, "..", "examples", "workflows", "skill-tree-demo"), + ); + const lock = bundle.lock; + if (lock?.schemaVersion !== "0.2") { + throw new Error("Expected the skill-tree demo to have a transitive lock"); + } + const rootWorkflow = lock.workflows[0]; + if (!rootWorkflow) { + throw new Error("Expected the transitive lock to have a root workflow"); + } + bundle.lock = { + ...lock, + workflows: [ + { ...rootWorkflow, source: { kind: "local", path: "./tampered-root" } }, + ...lock.workflows.slice(1), + ], + }; + + await expect(resolveWorkflowDependencyGraph({ bundle })).rejects.toThrow( + "Transitive workflow lock has an invalid root source", + ); + }); + test("selects the highest leaf repository semver for one logical skill", async () => { const rootDir = await mkdtemp(join(tmpdir(), "workflow-leaf-version-")); await writeFile( @@ -163,6 +188,38 @@ describe("workflow bundles", () => { } }); + test("selects a versioned leaf over an unversioned default in either mixed leaf order", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "workflow-mixed-leaf-version-")); + + try { + for (const [index, repos] of [ + ["org/pkg@2.0.0", "org/pkg"], + ["org/pkg", "org/pkg@2.0.0"], + ].entries()) { + const bundleDir = join(rootDir, String(index)); + await mkdir(bundleDir, { recursive: true }); + await writeFile( + join(bundleDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: `mixed-leaf-version-${index}`, + version: "1.0.0", + description: "Mixed leaf version selection.", + skills: repos.map((repo) => ({ source: "shared", repo })), + steps: [{ id: "run", title: "Run", skill: "shared" }], + }), + ); + + const graph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(bundleDir), + }); + expect(graph.dependencies).toEqual([{ source: "shared", repo: "org/pkg@2.0.0" }]); + } + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("rejects invalid manifest semantic versions during schema parsing", () => { expect(() => WorkflowBundleManifestSchema.parse({ From 0d635240c22399d32afcf38e9781d771c39da38f Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 16:25:43 +0800 Subject: [PATCH 04/11] docs: design canonical team workflow members --- ...2026-07-14-team-workflow-members-design.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-team-workflow-members-design.md diff --git a/docs/superpowers/specs/2026-07-14-team-workflow-members-design.md b/docs/superpowers/specs/2026-07-14-team-workflow-members-design.md new file mode 100644 index 00000000..7aaa6494 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-team-workflow-members-design.md @@ -0,0 +1,157 @@ +# Team Members as Canonical Child Workflows + +## Goal + +Prevent team bundles from copying role `SKILL.md` files that already belong to +standalone workflows. A team should own its coordinator and composition, while +each standalone role workflow owns its role instructions and companion skills. + +`startup-team` remains the public package and the only installed workflow +record. Its callable coordinator remains `$startup-goal`. + +## Current Problem + +`examples/teams/startup-team/skills/` currently contains local copies of CEO, +CTO, product manager, web-design, engineering manager, founding engineer, and +QA lead skills. Equivalent standalone workflows exist under +`examples/workflows/`. The copies differ and can drift because both locations +can be edited independently. + +The current team validator reinforces this duplication by requiring every +`members[]` value to be a declared local skill path. + +## Ownership Model + +- The team owns `skills/startup-goal/SKILL.md`, its unique coordinator. +- Standalone role workflows own their entry skills and companion dependencies. +- A team member is a child workflow reference, not a copied local role skill. +- Installing a team expands child workflows into leaf skills but writes only + the root team install record. + +The standalone role workflow is authoritative. Team-specific copies are not +generated or synchronized. + +## Manifest Contract + +`startup-team` uses the existing string source format: + +```json +{ + "coordinator": "./skills/startup-goal", + "members": [ + "catalog:ceo", + "catalog:cto", + "catalog:product-manager", + "catalog:web-design", + "catalog:engineering-manager", + "catalog:founding-engineer", + "catalog:qa-lead" + ] +} +``` + +Every coordinator and member source must also appear in `skills[]`. Steps refer +to the same declared source strings. The team manifest no longer repeats the +child workflows' companion skills. + +The coordinator remains a declared local skill and the root entry skill. A +member may use any supported child-workflow source form—local workflow path, +repository/subdirectory, `catalog:`, or `installed:`—provided resolution proves +that it is a workflow. + +The manifest schema remains `0.1` because the JSON field shapes do not change. +Team validation semantics intentionally become stricter: local role skills are +no longer valid members. + +## Resolved Team Validation + +Synchronous manifest parsing continues to validate required fields, declared +source references, duplicate source strings, and coordinator/member separation. +Checks that require loading a child source run after dependency resolution. + +For each team member, the runtime must prove that: + +1. The declared source resolves to a child workflow. +2. The selected child workflow declares exactly one `entry: true` skill. +3. The entry skill is a local skill path within that child workflow. +4. The resolved workflow name is unique within the team. +5. The resolved entry skill name is unique within the team. + +The existing highest-SemVer child-workflow selection and cycle detection apply +before these checks. `validate`, `deps`, `lock`, and `install` must use the same +resolved-team validation function so their answers cannot diverge. + +Validation fails before target writes. Errors identify the member source and +the violated rule, including: + +- member did not resolve to a workflow; +- member workflow has no entry skill; +- member workflow has multiple entry skills; +- member entry skill is not local; +- duplicate resolved workflow or entry-skill name; or +- dependency cycle. + +## Resolution and Installation Flow + +1. Load and parse the root team manifest. +2. Resolve the recursive workflow dependency graph. +3. Select the highest workflow version for every logical child workflow. +4. Validate the root coordinator and resolved team-member entry skills. +5. Flatten the selected child workflows into deduplicated leaf skill installs. +6. Validate or generate the expanded lock graph. +7. Install the coordinator, member entry skills, and companion leaf skills. +8. Write one `startup-team` install record containing the resulting artifacts. + +Child workflow records are not registered separately. Installed callable names +remain `$startup-goal`, `$ceo`, `$cto`, `$product-manager`, `$web-design`, +`$engineering-manager`, `$founding-engineer`, and `$qa-lead`. + +## Lock Contract + +The schema `0.2` lock records the root team, child workflow nodes, workflow +edges, exact repository commits, and leaf fingerprints. Regenerating the +`startup-team` lock must capture the canonical standalone role entry skills and +their companion dependencies. Deleted team-local role paths must not remain in +the lock. + +## Repository Migration + +1. Keep `examples/teams/startup-team/skills/startup-goal/SKILL.md`. +2. Delete the seven duplicated team-member skill directories. +3. Replace team member and step sources with `catalog:` references. +4. Remove companion skills now supplied transitively by child workflows. +5. Regenerate `examples/teams/startup-team/workflow.lock.json`. +6. Update architecture, authoring, README, landing, and source-link content. +7. Make landing role links target the canonical standalone workflow skills. + +Existing team manifests with local skill members fail validation with migration +guidance to reference a child workflow. The public install command and package +name remain unchanged. + +## Verification + +Focused tests must prove: + +- local skill members are rejected; +- all supported child-workflow source forms can provide team members; +- zero-entry, multi-entry, non-local-entry, duplicate, and cyclic members fail; +- highest-version workflow selection determines the member entry skill; +- `validate`, `deps`, `lock`, and `install` enforce the same contract; +- each role entry skill installs exactly once; +- companion skills arrive transitively; +- graph failure causes no target writes; +- only the `startup-team` record is written; and +- landing/docs link to canonical standalone role sources. + +Run a clean-home `startup-team` install smoke, the relevant CLI smokes, and +`rtk bun run check`. The repository's 90% line-coverage gate remains required. + +## Non-Goals + +- Registering child workflows as separately installed workflows. +- Moving or rewording the canonical standalone role skills. +- Generating synchronized team-local copies. +- Replacing the team-specific `startup-goal` coordinator. +- Changing role dispatch, approval gates, or runtime subagent behavior. +- Expanding repository URL version parsing beyond the existing dependency + resolution contract. From bc4064de953aa593a03410521fc95c1731076ad3 Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 16:35:38 +0800 Subject: [PATCH 05/11] docs: plan canonical team workflow members --- .../plans/2026-07-14-team-workflow-members.md | 719 ++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-team-workflow-members.md diff --git a/docs/superpowers/plans/2026-07-14-team-workflow-members.md b/docs/superpowers/plans/2026-07-14-team-workflow-members.md new file mode 100644 index 00000000..7f25987e --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-team-workflow-members.md @@ -0,0 +1,719 @@ +# Canonical Team Workflow Members Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove duplicated startup-team role skills by making team members resolve to canonical child workflow entry skills. + +**Architecture:** Keep the team coordinator as one local entry skill, resolve every `members[]` source through the existing recursive workflow graph, and validate the selected child workflow's single local entry skill before installation. Migrate `startup-team` to `catalog:` members, retain one root install record, and point landing links at the canonical standalone workflow sources. + +**Tech Stack:** Bun, TypeScript, Commander, Zod, Bun test, Biome, Omniskills workflow manifests and schema `0.2` lock files. + +**Execution rule:** Before each task's first file mutation, use `pony-trail` to record a pre-change snapshot for exactly that task's files. After its final mutation and focused checks, record the matching post-change snapshot before staging or committing. + +--- + +## File Map + +- `src/runtimes/omniskill/workflow-bundles.ts` — synchronous team manifest rules, recursive member resolution, and resolved-team validation. +- `src/omniskill.ts` — make `validate` run the same dependency graph validation as `deps`, `lock`, and `install`. +- `tests/workflow-bundles.test.ts` — public runtime-seam coverage for member workflows and failure modes. +- `tests/omniskill.test.ts` — CLI validation, pre-write failure, install artifact, and root-record coverage. +- `examples/teams/startup-team/workflow.json` — direct coordinator, child workflow members, and root-only steps. +- `examples/teams/startup-team/workflow.lock.json` — generated expanded child workflow graph and leaf fingerprints. +- `examples/teams/startup-team/skills/{ceo,cto,product-manager,web-design,engineering-manager,founding-engineer,qa-lead}/SKILL.md` — delete duplicated role definitions. +- `landing/lib/landing-content.ts` — canonical skill source URLs and startup-team local-skill ownership. +- `landing/components/workflow-detail.tsx` — use canonical skill links. +- `landing/app/workflows/[slug]/page.tsx` — use canonical skill links on static detail routes. +- `landing/components/workflow-run-demo.tsx` — link the coordinator to the team and roles to standalone workflows. +- `tests/landing-app.test.ts` — manifest/member parity and canonical source-link coverage. +- `AGENTS.md`, `docs/architecture.md`, `docs/workflow-author-guide.md` — authoritative team authoring contract. +- `tests/readme.test.ts` — repository-guidance contract coverage. + +### Task 1: Validate Team Members Through the Recursive Workflow Graph + +**Files:** +- Modify: `src/runtimes/omniskill/workflow-bundles.ts:77-150` +- Modify: `src/runtimes/omniskill/workflow-bundles.ts:717-890` +- Test: `tests/workflow-bundles.test.ts:430-560` +- Test: `tests/workflow-bundles.test.ts:1080-1215` + +- [ ] **Step 1: Add a fixture helper for team and child workflow manifests** + +Add this helper beside the existing workflow fixture helpers in `tests/workflow-bundles.test.ts`: + +```ts +async function writeTeamWithMemberFixture(input: { + rootDir: string; + memberSource: string; + childEntrySources?: Array<{ source: string; entry?: boolean }>; +}): Promise<{ teamDir: string; childDir: string }> { + const teamDir = join(input.rootDir, "team"); + const childDir = join(input.rootDir, "child"); + await mkdir(join(teamDir, "skills", "coordinator"), { recursive: true }); + await writeFile(join(teamDir, "skills", "coordinator", "SKILL.md"), "# coordinator\n"); + await mkdir(join(childDir, "skills", "member"), { recursive: true }); + await writeFile(join(childDir, "skills", "member", "SKILL.md"), "# member\n"); + await writeFile( + join(childDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "member-workflow", + version: "1.0.0", + description: "Canonical member workflow.", + skills: input.childEntrySources ?? [{ source: "./skills/member", entry: true }], + steps: [{ id: "member", title: "Member", skill: "./skills/member" }], + }), + ); + await writeFile( + join(teamDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + kind: "team", + name: "test-team", + version: "1.0.0", + description: "Team with one canonical member workflow.", + coordinator: "./skills/coordinator", + members: [input.memberSource], + skills: [ + { source: "./skills/coordinator", entry: true }, + { source: input.memberSource }, + ], + steps: [ + { id: "coordinate", title: "Coordinate", skill: "./skills/coordinator" }, + { id: "member", title: "Member", skill: input.memberSource }, + ], + }), + ); + return { teamDir, childDir }; +} +``` + +- [ ] **Step 2: Write the failing public-seam tests** + +Add tests that call `resolveWorkflowDependencyGraph`, not private helpers: + +```ts +test("resolves a team member to one canonical child workflow entry skill", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "team-member-workflow-")); + try { + const { teamDir } = await writeTeamWithMemberFixture({ + rootDir, + memberSource: "../child", + }); + const graph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(teamDir), + }); + + expect(graph.workflows.map(({ name }) => name)).toEqual(["test-team", "member-workflow"]); + expect(graph.edges).toEqual([{ from: "test-team@1.0.0", to: "member-workflow@1.0.0" }]); + expect(graph.dependencies.map(({ source }) => source)).toEqual([ + join(teamDir, "skills", "coordinator"), + join(rootDir, "child", "skills", "member"), + ]); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); + +test("rejects a copied local skill as a team member", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "team-local-member-")); + try { + const { teamDir } = await writeTeamWithMemberFixture({ + rootDir, + memberSource: "./skills/copied-member", + }); + await mkdir(join(teamDir, "skills", "copied-member"), { recursive: true }); + await writeFile(join(teamDir, "skills", "copied-member", "SKILL.md"), "# copied\n"); + + await expect( + resolveWorkflowDependencyGraph({ bundle: await loadWorkflowBundle(teamDir) }), + ).rejects.toThrow( + "Team member must reference a child workflow with exactly one local entry skill: ./skills/copied-member", + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); +``` + +Add focused cases for zero entry, a non-local entry, duplicate resolved workflow names, duplicate entry names, and an existing cycle. A child manifest with multiple entries should assert the existing Zod error `Only one workflow skill can be marked as entry` while naming the member source in the wrapping resolution error. + +Extend the existing repository/subdirectory, `catalog:`, and `installed:` resolver tests so each parent is a valid team with a local coordinator and the resolved child skill is marked `entry: true`. Together with the local `../child` case above, these tests prove every supported member source form. + +Extend the existing highest-child-SemVer test so the root is a team whose member points to the lower version while another non-member dependency discovers the higher version of the same workflow. Mark each candidate's distinct local skill as its entry. Assert the selected member entry and flattened dependency come only from version `1.10.0`. + +- [ ] **Step 3: Run the tests and verify the intended failures** + +Run: + +```bash +rtk bun test tests/workflow-bundles.test.ts -t "team member" +``` + +Expected: the canonical child case fails because non-local team members are rejected synchronously, and the copied local member case resolves as a leaf instead of failing the resolved-team rule. + +- [ ] **Step 4: Relax only the synchronous member-path restriction** + +In `WorkflowBundleManifestSchema.superRefine`, retain declaration, duplicate, and coordinator/member overlap checks, but remove this local-path rejection: + +```ts +if (!isLocalWorkflowSkillSource(member)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Team member must be a local skill path: ${member}`, + path: ["members", index], + }); +} +``` + +Add a coordinator entry check after resolving `coordinatorSkill` from `manifest.skills`: + +```ts +const coordinatorSkill = manifest.skills.find( + (skill) => skill.source === manifest.coordinator, +); +if (coordinatorSkill && coordinatorSkill.entry !== true) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Team coordinator must be marked as the entry skill", + path: ["coordinator"], + }); +} +``` + +- [ ] **Step 5: Add one resolved-team validation module inside the runtime** + +First wrap root-team member resolution errors at the `resolver.resolve` call inside `discover`. This preserves the child parser's original error while identifying which member source failed: + +```ts +const memberSource = + bundle === input.bundle && bundle.manifest.kind === "team" + ? bundle.manifest.members?.find((source) => source === bundle.manifest.skills[index]?.source) + : undefined; +let child: WorkflowBundle | null; +try { + child = await resolver.resolve({ dependency, parent: bundle }); +} catch (error) { + if (!memberSource) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to resolve team member ${memberSource}: ${message}`, { cause: error }); +} +``` + +Then keep resolved-team validation private to `workflow-bundles.ts`: + +```ts +function validateResolvedTeamMembers(input: { + root: WorkflowBundle; + resolvedChildren: Map; + selectedByName: Map; +}): void { + if (input.root.manifest.kind !== "team") return; + + const rootId = getCanonicalWorkflowIdentity(input.root); + const workflowNames = new Set(); + const entryNames = new Set(); + + for (const member of input.root.manifest.members ?? []) { + const index = input.root.manifest.skills.findIndex((skill) => skill.source === member); + const discovered = input.resolvedChildren.get(`${rootId}\n${index}`); + const child = discovered + ? (input.selectedByName.get(discovered.manifest.name) ?? discovered) + : null; + const entries = child?.manifest.skills.filter((skill) => skill.entry === true) ?? []; + const entry = entries[0]; + if (!child || entries.length !== 1 || !entry || !isLocalWorkflowSkillSource(entry.source)) { + throw new Error( + `Team member must reference a child workflow with exactly one local entry skill: ${member}`, + ); + } + if (workflowNames.has(child.manifest.name)) { + throw new Error(`Duplicate resolved team workflow: ${child.manifest.name}`); + } + const entryName = basename(entry.source); + if (entryNames.has(entryName)) { + throw new Error(`Duplicate resolved team entry skill: ${entryName}`); + } + workflowNames.add(child.manifest.name); + entryNames.add(entryName); + } +} +``` + +Call it immediately after discovery and before legacy/transitive lock validation: + +```ts +validateResolvedTeamMembers({ + root: input.bundle, + resolvedChildren, + selectedByName, +}); +``` + +Member indexes must continue to match the manifest skill order used by `resolvedChildren`. + +- [ ] **Step 6: Run focused runtime tests** + +Run: + +```bash +rtk bun test tests/workflow-bundles.test.ts -t "team" +rtk bun run typecheck +``` + +Expected: all team tests pass and TypeScript reports no errors. + +- [ ] **Step 7: Commit the runtime contract** + +```bash +rtk git add src/runtimes/omniskill/workflow-bundles.ts tests/workflow-bundles.test.ts +rtk git commit -m "feat: resolve team members from child workflows" +``` + +### Task 2: Make Every CLI Command Enforce Resolved Team Validation + +**Files:** +- Modify: `src/omniskill.ts:255-275` +- Modify: `tests/omniskill.test.ts:47-126` +- Test: `tests/omniskill.test.ts:930-1080` + +- [ ] **Step 1: Migrate the CLI team fixture to a child workflow member** + +When `options.team` is true in `writeGitWorkflowFixtureAt`, create +`member-workflow/skills/git-extra/SKILL.md` and its workflow manifest. The root +team uses `members: ["./member-workflow"]`, declares `./member-workflow` in +`skills[]`, and uses it in the member step. Keep `./skills/git-entry` as the +coordinator with `entry: true`. + +Use this child manifest: + +```ts +await writeFile( + join(workflowDir, "member-workflow", "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "git-member", + version: "1.0.0", + description: "Canonical member workflow.", + skills: [{ source: "./skills/git-extra", entry: true }], + steps: [{ id: "member", title: "Member", skill: "./skills/git-extra" }], + }), +); +``` + +- [ ] **Step 2: Write failing CLI tests for validate and pre-write install failure** + +Add: + +```ts +test("validate resolves team member workflows", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "omniskill-team-validate-")); + const program = new Command(); + try { + await writeGitWorkflowFixtureAt(rootDir, { team: true }); + configureOmniskillCommand(program, { rootDir }); + await expect( + program.parseAsync(["validate", rootDir], { from: "user" }), + ).resolves.toBeDefined(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); +``` + +Add a second fixture whose member is `./skills/git-extra`, call `install`, and +assert the fake `installSkill` call count is zero and no root workflow record +exists. + +Use that same invalid fixture with fresh `Command` instances for `validate`, `deps`, and `lock`. Assert all three reject with `Team member must reference a child workflow`, matching `install`. For the valid fixture, assert install creates only `.omniskills/workflows/git-team.json`; it must not create a `git-member.json` child record. + +- [ ] **Step 3: Run the CLI tests and verify validate is falsely green** + +```bash +rtk bun test tests/omniskill.test.ts -t "team" +``` + +Expected: the invalid local-member `validate` case passes because the current validate command only loads the manifest. + +- [ ] **Step 4: Resolve and clean up the graph in the validate command** + +Replace the validate action body with this shape: + +```ts +const bundle = await loadWorkflowBundle(path, { + cwd: options.rootDir, + ...(options.workflowGitCommandRunner + ? { runGitCommand: options.workflowGitCommandRunner } + : {}), +}); +let graph: Awaited> | undefined; +try { + graph = await resolveWorkflowDependencyGraph({ + bundle, + ...(options.workflowGitCommandRunner + ? { runGitCommand: options.workflowGitCommandRunner } + : {}), + installedRootDir: options.rootDir, + }); + console.log(success(`Omniskills valid: ${bundle.manifest.name}@${bundle.manifest.version}`)); + console.log(keyValue("Steps", String(bundle.manifest.steps.length))); + console.log(keyValue("Skills", String(graph.dependencies.length))); +} finally { + await graph?.cleanup?.(); + await bundle.cleanup?.(); +} +``` + +Use the existing inline load options rather than introducing a second command-specific adapter. + +- [ ] **Step 5: Run focused CLI tests and typecheck** + +```bash +rtk bun test tests/omniskill.test.ts -t "team" +rtk bun test tests/omniskill.test.ts -t "validate" +rtk bun run typecheck +``` + +Expected: validate, install ordering, coordinator next-step output, and cleanup tests pass. + +- [ ] **Step 6: Commit the shared CLI validation path** + +```bash +rtk git add src/omniskill.ts tests/omniskill.test.ts +rtk git commit -m "fix: validate resolved team members in every command" +``` + +### Task 3: Migrate Startup Team to Canonical Role Workflows + +**Files:** +- Modify: `examples/teams/startup-team/workflow.json` +- Modify: `examples/teams/startup-team/workflow.lock.json` +- Delete: `examples/teams/startup-team/skills/ceo/SKILL.md` +- Delete: `examples/teams/startup-team/skills/cto/SKILL.md` +- Delete: `examples/teams/startup-team/skills/product-manager/SKILL.md` +- Delete: `examples/teams/startup-team/skills/web-design/SKILL.md` +- Delete: `examples/teams/startup-team/skills/engineering-manager/SKILL.md` +- Delete: `examples/teams/startup-team/skills/founding-engineer/SKILL.md` +- Delete: `examples/teams/startup-team/skills/qa-lead/SKILL.md` +- Test: `tests/workflow-bundles.test.ts:1080-1215` + +- [ ] **Step 1: Rewrite the startup-team contract test first** + +Change the expected members and role steps to: + +```ts +const canonicalMembers = [ + "catalog:ceo", + "catalog:cto", + "catalog:product-manager", + "catalog:web-design", + "catalog:engineering-manager", + "catalog:founding-engineer", + "catalog:qa-lead", +]; +expect(bundle.manifest.members).toEqual(canonicalMembers); +expect(bundle.manifest.steps.map((step) => [step.id, step.skill])).toEqual([ + ["requirements", "superpowers:brainstorming"], + ["route", "./skills/startup-goal"], + ["strategy", "catalog:ceo"], + ["product", "catalog:product-manager"], + ["design", "catalog:web-design"], + ["technology", "catalog:cto"], + ["delivery", "catalog:engineering-manager"], + ["implementation", "catalog:founding-engineer"], + ["implement", "mattpocock:implement"], + ["qa", "catalog:qa-lead"], +]); +for (const role of canonicalMembers.map((source) => source.slice("catalog:".length))) { + await expect( + stat(join(import.meta.dir, "..", "examples", "teams", "startup-team", "skills", role)), + ).rejects.toThrow(); +} +``` + +Also assert that the expanded graph contains the root plus seven selected child +workflows and seven root edges, and that all seven canonical entry skill names +appear once in the leaf install plan. + +- [ ] **Step 2: Run the startup-team test and verify it fails** + +```bash +rtk bun test tests/workflow-bundles.test.ts -t "startup team" +``` + +Expected: members and steps still reference copied `./skills/` paths. + +- [ ] **Step 3: Replace direct role copies with child workflow sources** + +Keep only these direct non-member skills in `workflow.json`: + +```json +[ + { "source": "./skills/startup-goal", "entry": true }, + { "source": "catalog:ceo" }, + { "source": "catalog:cto" }, + { "source": "catalog:product-manager" }, + { "source": "catalog:web-design" }, + { "source": "catalog:engineering-manager" }, + { "source": "catalog:founding-engineer" }, + { "source": "catalog:qa-lead" }, + { "source": "superpowers:brainstorming", "repo": "obra/superpowers" }, + { + "source": "mattpocock:implement", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" + } +] +``` + +Update `members[]` and role `steps[].skill` to the matching `catalog:` values. + +- [ ] **Step 4: Delete the duplicated role skill directories** + +Delete only the seven paths listed in this task. Confirm the coordinator remains: + +```bash +rtk rg --files examples/teams/startup-team/skills +``` + +Expected output: only `examples/teams/startup-team/skills/startup-goal/SKILL.md`. + +- [ ] **Step 5: Regenerate and inspect the expanded lock** + +```bash +rtk bun run dev -- lock examples/teams/startup-team +rtk bun run dev -- validate examples/teams/startup-team +rtk bun run dev -- deps examples/teams/startup-team +``` + +Expected: lock schema `0.2`, eight workflow nodes, seven root-to-member edges, +no deleted team-local role paths, and one leaf dependency per canonical entry +skill after deduplication. + +- [ ] **Step 6: Run focused example tests** + +```bash +rtk bun test tests/workflow-bundles.test.ts -t "startup team" +rtk bun test tests/workflow-bundles.test.ts -t "curated workflow examples" +rtk bun run typecheck +``` + +Expected: all commands pass. + +- [ ] **Step 7: Commit the startup-team migration** + +```bash +rtk git add examples/teams/startup-team tests/workflow-bundles.test.ts +rtk git commit -m "refactor: reuse canonical startup role workflows" +``` + +### Task 4: Point Landing and Documentation at Canonical Role Sources + +**Files:** +- Modify: `landing/lib/landing-content.ts:12-55` +- Modify: `landing/lib/landing-content.ts:74-155` +- Modify: `landing/components/workflow-detail.tsx` +- Modify: `landing/app/workflows/[slug]/page.tsx` +- Modify: `landing/components/workflow-run-demo.tsx:80-115` +- Modify: `tests/landing-app.test.ts:250-300` +- Modify: `tests/landing-app.test.ts:540-565` +- Modify: `AGENTS.md` +- Modify: `docs/architecture.md:135-160` +- Modify: `docs/workflow-author-guide.md:390-425` +- Modify: `tests/readme.test.ts:90-115` + +- [ ] **Step 1: Write failing landing source-link and manifest-parity tests** + +Replace the flat roster assertion with contract assertions: + +```ts +import { githubUrl, startupTeam } from "../landing/lib/landing-content"; + +const memberSkills = startupTeam.members.map(({ skill }) => skill); +expect(manifest.members).toEqual(memberSkills.map((skill) => `catalog:${skill}`)); +expect(startupTeam.localSkillNames).toEqual(["startup-goal"]); +for (const skill of memberSkills) { + expect(startupTeam.skillSourceUrls?.[skill]).toBe( + `${githubUrl}/blob/main/examples/workflows/${skill}/skills/${skill}/SKILL.md`, + ); +} +``` + +Update the workflow-run-demo source test to require both canonical roots: + +```ts +expect(demo).toContain("examples/teams/startup-team/skills/startup-goal/SKILL.md"); +expect(demo).toContain("examples/workflows/${skill}/skills/${skill}/SKILL.md"); +expect(demo).not.toContain("examples/teams/startup-team/skills/${skill}/SKILL.md"); +``` + +- [ ] **Step 2: Run landing tests and verify links still target deleted files** + +```bash +rtk bun test tests/landing-app.test.ts -t "startup-team" +rtk bun test tests/landing-app.test.ts -t "parallel startup-goal" +``` + +Expected: local skill ownership and canonical role URL assertions fail. + +- [ ] **Step 3: Add explicit canonical skill URLs to landing content** + +Add the optional URL map to `CatalogEntryBase` after `sourceUrl: string`, then replace the helper: + +```ts +skillSourceUrls?: Record; + +export function getSkillSourceUrl(workflow: CatalogEntryContent, skill: string) { + const explicitSource = workflow.skillSourceUrls?.[skill]; + if (explicitSource) return explicitSource; + if (!workflow.localSkillNames.includes(skill)) return null; + return `${workflow.sourceUrl.replace("/tree/", "/blob/")}/skills/${skill}/SKILL.md`; +} +``` + +Set `startupTeam.localSkillNames` to `["startup-goal"]` and add: + +```ts +skillSourceUrls: { + "startup-goal": `${githubUrl}/blob/main/examples/teams/startup-team/skills/startup-goal/SKILL.md`, + ceo: `${githubUrl}/blob/main/examples/workflows/ceo/skills/ceo/SKILL.md`, + cto: `${githubUrl}/blob/main/examples/workflows/cto/skills/cto/SKILL.md`, + "product-manager": `${githubUrl}/blob/main/examples/workflows/product-manager/skills/product-manager/SKILL.md`, + "web-design": `${githubUrl}/blob/main/examples/workflows/web-design/skills/web-design/SKILL.md`, + "engineering-manager": `${githubUrl}/blob/main/examples/workflows/engineering-manager/skills/engineering-manager/SKILL.md`, + "founding-engineer": `${githubUrl}/blob/main/examples/workflows/founding-engineer/skills/founding-engineer/SKILL.md`, + "qa-lead": `${githubUrl}/blob/main/examples/workflows/qa-lead/skills/qa-lead/SKILL.md`, +}, +``` + +Rename imports and calls from `getLocalSkillSourceUrl` to `getSkillSourceUrl` in both detail components. + +- [ ] **Step 4: Split workflow-run-demo coordinator and role URL generation** + +Replace the single team root with: + +```ts +const STARTUP_TEAM_SOURCE_ROOT = + "https://github.com/devos-ing/omni-skills/blob/main/examples/teams/startup-team"; +const ROLE_WORKFLOW_SOURCE_ROOT = + "https://github.com/devos-ing/omni-skills/blob/main/examples/workflows"; + +function skillSourceUrl(skill: SkillId) { + return skill === "startup-goal" + ? `${STARTUP_TEAM_SOURCE_ROOT}/skills/startup-goal/SKILL.md` + : `${ROLE_WORKFLOW_SOURCE_ROOT}/${skill}/skills/${skill}/SKILL.md`; +} +``` + +- [ ] **Step 5: Update authoritative team guidance** + +Change `AGENTS.md`, `docs/architecture.md`, and `docs/workflow-author-guide.md` +to state: + +```md +A team coordinator is one declared local entry skill. Every `members[]` source +must be declared in `skills[]` and resolve to a child workflow with exactly one +local entry skill. Member workflow dependencies are expanded recursively; only +the root team install record is written. +``` + +Update `tests/readme.test.ts` to require `resolve to a child workflow` and to +reject the old phrase `unique local declared members`. + +- [ ] **Step 6: Run landing and guidance tests** + +```bash +rtk bun test tests/landing-app.test.ts +rtk bun test tests/landing-content-markdown.test.ts +rtk bun test tests/readme.test.ts +rtk bun run typecheck +``` + +Expected: all tests pass and canonical source URLs point to existing standalone role files. + +- [ ] **Step 7: Commit landing and documentation parity** + +```bash +rtk git add AGENTS.md docs/architecture.md docs/workflow-author-guide.md landing tests/landing-app.test.ts tests/readme.test.ts +rtk git commit -m "docs: link startup team to canonical role workflows" +``` + +### Task 5: Verify Clean Installation and Update the Existing PR + +**Files:** +- Verify: all files changed by Tasks 1-4 +- Generated smoke state: `work/team-workflow-members-smoke/` + +- [ ] **Step 1: Run focused negative and install-order tests** + +```bash +rtk bun test tests/workflow-bundles.test.ts -t "team member" +rtk bun test tests/omniskill.test.ts -t "team" +rtk bun test tests/omniskill.test.ts -t "before target writes" +``` + +Expected: invalid members fail, valid child members expand, and the installer is never called before graph validation succeeds. + +- [ ] **Step 2: Run CLI smokes against the checked-in team** + +```bash +rtk bun run dev -- validate examples/teams/startup-team +rtk bun run dev -- deps examples/teams/startup-team +rtk bun run dev -- lock examples/teams/startup-team +rtk git diff --exit-code -- examples/teams/startup-team/workflow.lock.json +``` + +Expected: validate and deps succeed; regenerating the lock produces no diff. + +- [ ] **Step 3: Run a clean-home install smoke** + +Use a new scratch directory that does not exist before this run: + +```bash +rtk bun run dev -- install examples/teams/startup-team \ + --home work/team-workflow-members-smoke \ + --agents codex +rtk rg --files work/team-workflow-members-smoke/.omniskills/workflows +rtk rg --files work/team-workflow-members-smoke/.agents/skills +``` + +Expected: the workflow-record listing contains only `startup-team.json`; the +skill listing includes `startup-goal`, the seven canonical role entry skills, +and their deduplicated companions. + +- [ ] **Step 4: Run the full repository gate** + +```bash +rtk bun run check +``` + +Expected: Biome, TypeScript, all Bun tests, and the 90% line-coverage gate pass. + +- [ ] **Step 5: Review the complete implementation against the design commit** + +Use fixed point `0d63524`: + +```bash +rtk git diff 0d63524...HEAD --stat +rtk git log 0d63524..HEAD --oneline +rtk git diff --check +``` + +Run the two-axis `mattpocock:code-review` workflow with: + +- standards sources: `AGENTS.md`, `docs/architecture.md`, `biome.json`; +- spec source: `docs/superpowers/specs/2026-07-14-team-workflow-members-design.md`. + +Address every actionable finding with a new red-green test and a focused fix commit. Rerun `rtk bun run check` after the final fix. + +- [ ] **Step 6: Confirm branch and PR scope, then push** + +```bash +rtk git status --short --branch +rtk git log origin/main..HEAD --oneline +rtk git push origin codex/recursive-workflow-skill-tree +rtk gh pr view 373 --repo devos-ing/omni-skills --json url,isDraft,headRefName,baseRefName,state +``` + +Expected: clean branch, PR `#373` remains open as a draft from +`codex/recursive-workflow-skill-tree` to `main`, and all implementation commits +are visible remotely. From a0853a449e7e84c7efce61b2d45524f006b642f1 Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 16:48:45 +0800 Subject: [PATCH 06/11] feat: resolve team members from child workflows --- src/runtimes/omniskill/workflow-bundles.ts | 98 +++++- tests/workflow-bundles.test.ts | 373 ++++++++++++++++++--- 2 files changed, 414 insertions(+), 57 deletions(-) diff --git a/src/runtimes/omniskill/workflow-bundles.ts b/src/runtimes/omniskill/workflow-bundles.ts index e016d10b..1e19d6c4 100644 --- a/src/runtimes/omniskill/workflow-bundles.ts +++ b/src/runtimes/omniskill/workflow-bundles.ts @@ -106,6 +106,16 @@ export const WorkflowBundleManifestSchema = z path: ["coordinator"], }); } + const coordinatorSkill = manifest.skills.find( + (skill) => skill.source === manifest.coordinator, + ); + if (coordinatorSkill && coordinatorSkill.entry !== true) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Team coordinator must be marked as the entry skill", + path: ["coordinator"], + }); + } } if (!manifest.members || manifest.members.length === 0) { @@ -124,13 +134,6 @@ export const WorkflowBundleManifestSchema = z path: ["members", index], }); } - if (!isLocalWorkflowSkillSource(member)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: `Team member must be a local skill path: ${member}`, - path: ["members", index], - }); - } if (seenMembers.has(member)) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -714,6 +717,44 @@ export function getWorkflowSkillInstallDependencies( }); } +function validateResolvedTeamMembers(input: { + root: WorkflowBundle; + resolvedChildren: Map; + selectedByName: Map; +}): void { + if (input.root.manifest.kind !== "team") { + return; + } + + const rootId = getCanonicalWorkflowIdentity(input.root); + const workflowNames = new Set(); + const entryNames = new Set(); + + for (const member of input.root.manifest.members ?? []) { + const index = input.root.manifest.skills.findIndex((skill) => skill.source === member); + const discovered = input.resolvedChildren.get(`${rootId}\n${index}`); + const child = discovered + ? (input.selectedByName.get(discovered.manifest.name) ?? discovered) + : null; + const entries = child?.manifest.skills.filter((skill) => skill.entry === true) ?? []; + const entry = entries[0]; + if (!child || entries.length !== 1 || !entry || !isLocalWorkflowSkillSource(entry.source)) { + throw new Error( + `Team member must reference a child workflow with exactly one local entry skill: ${member}`, + ); + } + if (workflowNames.has(child.manifest.name)) { + throw new Error(`Duplicate resolved team workflow: ${child.manifest.name}`); + } + const entryName = basename(entry.source); + if (entryNames.has(entryName)) { + throw new Error(`Duplicate resolved team entry skill: ${entryName}`); + } + workflowNames.add(child.manifest.name); + entryNames.add(entryName); + } +} + export async function resolveWorkflowDependencyGraph(input: { bundle: WorkflowBundle; resolver?: WorkflowDependencyResolver; @@ -764,15 +805,31 @@ export async function resolveWorkflowDependencyGraph(input: { const nextActive = [...active, bundle]; for (const [index, dependency] of getWorkflowSkillInstallDependencies(bundle).entries()) { - const child = await resolver.resolve({ dependency, parent: bundle }); - if (!child) { - continue; - } - resolvedChildren.set(`${canonicalId}\n${index}`, child); - if (child.cleanup) { - cleanups.push(child.cleanup); + const memberSource = + bundle === input.bundle && bundle.manifest.kind === "team" + ? bundle.manifest.members?.find( + (source) => source === bundle.manifest.skills[index]?.source, + ) + : undefined; + try { + const child = await resolver.resolve({ dependency, parent: bundle }); + if (!child) { + continue; + } + resolvedChildren.set(`${canonicalId}\n${index}`, child); + if (child.cleanup) { + cleanups.push(child.cleanup); + } + await discover(child, nextActive); + } catch (error) { + if (!memberSource) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to resolve team member ${memberSource}: ${message}`, { + cause: error, + }); } - await discover(child, nextActive); } }; @@ -783,6 +840,17 @@ export async function resolveWorkflowDependencyGraph(input: { throw error; } + try { + validateResolvedTeamMembers({ + root: input.bundle, + resolvedChildren, + selectedByName, + }); + } catch (error) { + await Promise.allSettled(cleanups.map((cleanup) => cleanup())); + throw error; + } + if ( !input.ignoreLockValidation && input.bundle.lock?.schemaVersion === "0.1" && diff --git a/tests/workflow-bundles.test.ts b/tests/workflow-bundles.test.ts index 9f6909bb..5115b0c9 100644 --- a/tests/workflow-bundles.test.ts +++ b/tests/workflow-bundles.test.ts @@ -63,15 +63,15 @@ const validTeamManifest = { version: "0.1.0", description: "A test team with one coordinator and one member.", coordinator: "./skills/coordinator", - members: ["./skills/member"], + members: ["catalog:member-workflow"], skills: [ { source: "./skills/coordinator", entry: true }, - { source: "./skills/member" }, + { source: "catalog:member-workflow" }, { source: "external-review" }, ], steps: [ { id: "route", title: "Route work", skill: "./skills/coordinator" }, - { id: "review", title: "Review work", skill: "./skills/member" }, + { id: "review", title: "Review work", skill: "catalog:member-workflow" }, ], } as const; @@ -107,6 +107,54 @@ async function writeTeamBundleFixtureAt(bundleDir: string, kind: "team" | "workf ); } +async function writeTeamWithMemberFixture(input: { + rootDir: string; + memberSource: string; + childSkills?: Array<{ source: string; entry?: boolean }>; +}): Promise<{ teamDir: string; childDir: string }> { + const teamDir = join(input.rootDir, "team"); + const childDir = join(input.rootDir, "child"); + const childSkills = input.childSkills ?? [{ source: "./skills/member", entry: true }]; + await mkdir(join(teamDir, "skills", "coordinator"), { recursive: true }); + await mkdir(childDir, { recursive: true }); + await writeFile(join(teamDir, "skills", "coordinator", "SKILL.md"), "# coordinator\n"); + for (const skill of childSkills) { + if (skill.source.startsWith("./skills/")) { + await mkdir(join(childDir, skill.source), { recursive: true }); + await writeFile(join(childDir, skill.source, "SKILL.md"), `# ${skill.source}\n`); + } + } + await writeFile( + join(childDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "member-workflow", + version: "1.0.0", + description: "Canonical member workflow.", + skills: childSkills, + steps: [{ id: "member", title: "Member", skill: childSkills[0]?.source }], + }), + ); + await writeFile( + join(teamDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + kind: "team", + name: "test-team", + version: "1.0.0", + description: "Team with one canonical member workflow.", + coordinator: "./skills/coordinator", + members: [input.memberSource], + skills: [{ source: "./skills/coordinator", entry: true }, { source: input.memberSource }], + steps: [ + { id: "coordinate", title: "Coordinate", skill: "./skills/coordinator" }, + { id: "member", title: "Member", skill: input.memberSource }, + ], + }), + ); + return { teamDir, childDir }; +} + describe("workflow bundles", () => { test("exports workflow bundle helpers from the Omniskills runtime namespace", async () => { const runtime = await import("../src/runtimes/omniskill/workflow-bundles"); @@ -115,6 +163,196 @@ describe("workflow bundles", () => { expect(typeof runtime.getPreparedWorkflowSkillInstallDependencies).toBe("function"); }); + test("rejects a copied local skill as a team member", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "team-local-member-")); + try { + const { teamDir } = await writeTeamWithMemberFixture({ + rootDir, + memberSource: "./skills/copied-member", + }); + await mkdir(join(teamDir, "skills", "copied-member"), { recursive: true }); + await writeFile(join(teamDir, "skills", "copied-member", "SKILL.md"), "# copied\n"); + + await expect( + resolveWorkflowDependencyGraph({ bundle: await loadWorkflowBundle(teamDir) }), + ).rejects.toThrow( + "Team member must reference a child workflow with exactly one local entry skill: ./skills/copied-member", + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("resolves a team member to one canonical child workflow entry skill", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "team-member-workflow-")); + try { + const { teamDir } = await writeTeamWithMemberFixture({ + rootDir, + memberSource: "../child", + }); + const graph = await resolveWorkflowDependencyGraph({ + bundle: await loadWorkflowBundle(teamDir), + }); + + expect(graph.workflows.map(({ name }) => name)).toEqual(["test-team", "member-workflow"]); + expect(graph.edges).toEqual([ + { from: "workflow:test-team@1.0.0", to: "workflow:member-workflow@1.0.0" }, + ]); + expect(graph.dependencies.map(({ source }) => source)).toEqual([ + join(teamDir, "skills", "coordinator"), + join(rootDir, "child", "skills", "member"), + ]); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("rejects team member workflows without one local entry skill", async () => { + const cases = [ + { + label: "zero entries", + childSkills: [{ source: "./skills/member" }], + message: + "Team member must reference a child workflow with exactly one local entry skill: ../child", + }, + { + label: "non-local entry", + childSkills: [{ source: "external-member", entry: true }], + message: + "Team member must reference a child workflow with exactly one local entry skill: ../child", + }, + { + label: "multiple entries", + childSkills: [ + { source: "./skills/member", entry: true }, + { source: "./skills/other", entry: true }, + ], + message: + /Failed to resolve team member \.\.\/child:[\s\S]*Only one workflow skill can be marked as entry/, + }, + ] as const; + + for (const testCase of cases) { + const rootDir = await mkdtemp(join(tmpdir(), `team-member-${testCase.label}-`)); + try { + const { teamDir } = await writeTeamWithMemberFixture({ + rootDir, + memberSource: "../child", + childSkills: [...testCase.childSkills], + }); + await expect( + resolveWorkflowDependencyGraph({ bundle: await loadWorkflowBundle(teamDir) }), + ).rejects.toThrow(testCase.message); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + } + }); + + test("rejects duplicate resolved team workflows and entry skill names", async () => { + const cases = [ + { + label: "workflow", + firstName: "shared-member", + secondName: "shared-member", + firstEntry: "first-entry", + secondEntry: "second-entry", + message: "Duplicate resolved team workflow: shared-member", + }, + { + label: "entry", + firstName: "first-member", + secondName: "second-member", + firstEntry: "shared-entry", + secondEntry: "shared-entry", + message: "Duplicate resolved team entry skill: shared-entry", + }, + ] as const; + + for (const testCase of cases) { + const rootDir = await mkdtemp(join(tmpdir(), `team-duplicate-${testCase.label}-`)); + const teamDir = join(rootDir, "team"); + const writeChild = async (directory: string, name: string, entryName: string) => { + await mkdir(join(directory, "skills", entryName), { recursive: true }); + await writeFile(join(directory, "skills", entryName, "SKILL.md"), `# ${entryName}\n`); + await writeFile( + join(directory, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name, + version: "1.0.0", + description: `${name} workflow.`, + skills: [{ source: `./skills/${entryName}`, entry: true }], + steps: [{ id: "run", title: "Run", skill: `./skills/${entryName}` }], + }), + ); + }; + + try { + await mkdir(join(teamDir, "skills", "coordinator"), { recursive: true }); + await writeFile(join(teamDir, "skills", "coordinator", "SKILL.md"), "# coordinator\n"); + await writeChild(join(rootDir, "first"), testCase.firstName, testCase.firstEntry); + await writeChild(join(rootDir, "second"), testCase.secondName, testCase.secondEntry); + await writeFile( + join(teamDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + kind: "team", + name: "duplicate-team", + version: "1.0.0", + description: "Team with duplicate resolved member identities.", + coordinator: "./skills/coordinator", + members: ["../first", "../second"], + skills: [ + { source: "./skills/coordinator", entry: true }, + { source: "../first" }, + { source: "../second" }, + ], + steps: [ + { id: "first", title: "First", skill: "../first" }, + { id: "second", title: "Second", skill: "../second" }, + ], + }), + ); + + await expect( + resolveWorkflowDependencyGraph({ bundle: await loadWorkflowBundle(teamDir) }), + ).rejects.toThrow(testCase.message); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + } + }); + + test("identifies the team member source when its workflow graph cycles", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "team-member-cycle-")); + try { + const { teamDir, childDir } = await writeTeamWithMemberFixture({ + rootDir, + memberSource: "../child", + }); + await writeFile( + join(childDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + name: "member-workflow", + version: "1.0.0", + description: "Member workflow that cycles to its team.", + skills: [{ source: "./skills/member", entry: true }, { source: "../team" }], + steps: [{ id: "member", title: "Member", skill: "./skills/member" }], + }), + ); + + await expect( + resolveWorkflowDependencyGraph({ bundle: await loadWorkflowBundle(teamDir) }), + ).rejects.toThrow( + "Failed to resolve team member ../child: Workflow dependency cycle: workflow:test-team@1.0.0 -> workflow:member-workflow@1.0.0 -> workflow:test-team@1.0.0", + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("rejects bare sources that are not valid workflow aliases", async () => { await expect(loadWorkflowBundle("ProductDev")).rejects.toThrow( "Unsupported Omniskills source: ProductDev", @@ -357,41 +595,52 @@ describe("workflow bundles", () => { } }); - test("selects the highest child workflow semver and replaces the losing subtree", async () => { + test("selects the highest child workflow semver for a team member", async () => { const rootDir = await mkdtemp(join(tmpdir(), "workflow-dependency-version-")); const parentDir = join(rootDir, "parent"); const lowDir = join(parentDir, "low"); const highDir = join(parentDir, "high"); - const writeManifest = async ( - bundleDir: string, - name: string, - version: string, - sources: string[], - ) => { - await mkdir(bundleDir, { recursive: true }); + const writeMemberManifest = async (bundleDir: string, version: string, entryName: string) => { + await mkdir(join(bundleDir, "skills", entryName), { recursive: true }); + await writeFile(join(bundleDir, "skills", entryName, "SKILL.md"), `# ${entryName}\n`); await writeFile( join(bundleDir, "workflow.json"), JSON.stringify( { schemaVersion: "0.1", - name, + name: "shared-child", version, - description: `${name} workflow.`, - skills: sources.map((source) => ({ source })), - steps: sources.map((source, index) => ({ - id: `step-${index}`, - title: `Step ${index}`, - skill: source, - })), + description: "Shared child workflow.", + skills: [{ source: `./skills/${entryName}`, entry: true }], + steps: [{ id: "run", title: "Run", skill: `./skills/${entryName}` }], }, null, 2, ), ); }; - await writeManifest(parentDir, "parent", "1.0.0", ["./low", "./high"]); - await writeManifest(lowDir, "shared-child", "1.2.0", ["low-only-skill"]); - await writeManifest(highDir, "shared-child", "1.10.0", ["high-only-skill"]); + await mkdir(join(parentDir, "skills", "coordinator"), { recursive: true }); + await writeFile(join(parentDir, "skills", "coordinator", "SKILL.md"), "# coordinator\n"); + await writeFile( + join(parentDir, "workflow.json"), + JSON.stringify({ + schemaVersion: "0.1", + kind: "team", + name: "parent", + version: "1.0.0", + description: "Team whose member has competing versions.", + coordinator: "./skills/coordinator", + members: ["./low"], + skills: [ + { source: "./skills/coordinator", entry: true }, + { source: "./low" }, + { source: "./high" }, + ], + steps: [{ id: "member", title: "Member", skill: "./low" }], + }), + ); + await writeMemberManifest(lowDir, "1.2.0", "low-only-skill"); + await writeMemberManifest(highDir, "1.10.0", "high-only-skill"); try { const graph = await resolveWorkflowDependencyGraph({ @@ -402,7 +651,10 @@ describe("workflow bundles", () => { "parent@1.0.0", "shared-child@1.10.0", ]); - expect(graph.dependencies).toEqual([{ source: "high-only-skill" }]); + expect(graph.dependencies.map(({ source }) => source)).toEqual([ + join(parentDir, "skills", "coordinator"), + join(highDir, "skills", "high-only-skill"), + ]); } finally { await rm(rootDir, { recursive: true, force: true }); } @@ -413,15 +665,19 @@ describe("workflow bundles", () => { const parentDir = join(rootDir, "parent"); const source = "https://github.com/acme/workflows.git#packages/child"; const commands: WorkflowGitCommand[] = []; - await mkdir(parentDir, { recursive: true }); + await mkdir(join(parentDir, "skills", "coordinator"), { recursive: true }); + await writeFile(join(parentDir, "skills", "coordinator", "SKILL.md"), "# coordinator\n"); await writeFile( join(parentDir, "workflow.json"), JSON.stringify({ schemaVersion: "0.1", + kind: "team", name: "parent", version: "1.0.0", - description: "Parent workflow.", - skills: [{ source }], + description: "Parent team.", + coordinator: "./skills/coordinator", + members: [source], + skills: [{ source: "./skills/coordinator", entry: true }, { source }], steps: [{ id: "child", title: "Child", skill: source }], }), ); @@ -443,7 +699,7 @@ describe("workflow bundles", () => { name: "child", version: "2.0.0", description: "Repository child.", - skills: [{ source: "./skills/child" }], + skills: [{ source: "./skills/child", entry: true }], steps: [{ id: "run", title: "Run", skill: "./skills/child" }], }), ); @@ -459,8 +715,8 @@ describe("workflow bundles", () => { version: "2.0.0", source: { kind: "git", url: source, commit: "abc123", subdirectory: "packages/child" }, }); - expect(graph.dependencies).toHaveLength(1); - expect(graph.dependencies[0]?.source).toContain("packages/child/skills/child"); + expect(graph.dependencies).toHaveLength(2); + expect(graph.dependencies[1]?.source).toContain("packages/child/skills/child"); await graph.cleanup?.(); } finally { await rm(rootDir, { recursive: true, force: true }); @@ -477,28 +733,48 @@ describe("workflow bundles", () => { name: string, version: string, source: string, + options: { team?: boolean; entry?: boolean } = {}, ) => { await mkdir(bundleDir, { recursive: true }); + if (options.team) { + await mkdir(join(bundleDir, "skills", "coordinator"), { recursive: true }); + await writeFile(join(bundleDir, "skills", "coordinator", "SKILL.md"), "# coordinator\n"); + } + if (options.entry && source.startsWith("./skills/")) { + await mkdir(join(bundleDir, source), { recursive: true }); + await writeFile(join(bundleDir, source, "SKILL.md"), `# ${name}\n`); + } await writeFile( join(bundleDir, "workflow.json"), JSON.stringify({ schemaVersion: "0.1", + ...(options.team + ? { kind: "team", coordinator: "./skills/coordinator", members: [source] } + : {}), name, version, description: `${name} workflow.`, - skills: [{ source }], + skills: [ + ...(options.team ? [{ source: "./skills/coordinator", entry: true }] : []), + { source, ...(options.entry ? { entry: true } : {}) }, + ], steps: [{ id: "run", title: "Run", skill: source }], }), ); }; - await writeManifest(catalogParentDir, "catalog-parent", "1.0.0", "catalog:catalog-child"); + await writeManifest(catalogParentDir, "catalog-parent", "1.0.0", "catalog:catalog-child", { + team: true, + }); await writeManifest( installedParentDir, "installed-parent", "1.0.0", "installed:installed-child", + { team: true }, ); - await writeManifest(installedChildDir, "installed-child", "3.0.0", "installed-leaf"); + await writeManifest(installedChildDir, "installed-child", "3.0.0", "./skills/installed-entry", { + entry: true, + }); await installWorkflowBundle({ rootDir, bundle: await loadWorkflowBundle(installedChildDir), @@ -514,7 +790,8 @@ describe("workflow bundles", () => { join(checkoutDir, "examples", "workflows", "catalog-child"), "catalog-child", "2.0.0", - "catalog-leaf", + "./skills/catalog-entry", + { entry: true }, ); return { stdout: "", stderr: "", exitCode: 0 }; } @@ -530,8 +807,12 @@ describe("workflow bundles", () => { { installedRootDir: rootDir, generatedAt: "2026-07-14T00:00:00.000Z" }, ); - expect(catalogGraph.dependencies).toEqual([{ source: "catalog-leaf" }]); - expect(installedGraph.dependencies).toEqual([{ source: "installed-leaf" }]); + expect(catalogGraph.dependencies).toHaveLength(2); + expect(catalogGraph.dependencies[1]?.source).toEndWith("skills/catalog-entry"); + expect(installedGraph.dependencies.map(({ source }) => source)).toEqual([ + join(installedParentDir, "skills", "coordinator"), + join(installedChildDir, "skills", "installed-entry"), + ]); expect(catalogGraph.workflows.map((workflow) => workflow.name)).toEqual([ "catalog-parent", "catalog-child", @@ -780,7 +1061,7 @@ describe("workflow bundles", () => { expect(team.kind).toBe("team"); expect(team.coordinator).toBe("./skills/coordinator"); - expect(team.members).toEqual(["./skills/member"]); + expect(team.members).toEqual(["catalog:member-workflow"]); expect(legacy.kind).toBeUndefined(); expect(getWorkflowInvocationSkillName(team)).toBe("coordinator"); expect(getWorkflowInvocationSkillName(legacy)).toBeNull(); @@ -805,16 +1086,24 @@ describe("workflow bundles", () => { message: "Team coordinator must be a local skill path: external-review", }, { - manifest: { ...validTeamManifest, members: ["./skills/missing"] }, - message: "Team member references unknown skill: ./skills/missing", + manifest: { + ...validTeamManifest, + skills: validTeamManifest.skills.map((skill) => + skill.source === validTeamManifest.coordinator ? { source: skill.source } : skill, + ), + }, + message: "Team coordinator must be marked as the entry skill", }, { - manifest: { ...validTeamManifest, members: ["external-review"] }, - message: "Team member must be a local skill path: external-review", + manifest: { ...validTeamManifest, members: ["./skills/missing"] }, + message: "Team member references unknown skill: ./skills/missing", }, { - manifest: { ...validTeamManifest, members: ["./skills/member", "./skills/member"] }, - message: "Duplicate team member: ./skills/member", + manifest: { + ...validTeamManifest, + members: ["catalog:member-workflow", "catalog:member-workflow"], + }, + message: "Duplicate team member: catalog:member-workflow", }, { manifest: { ...validTeamManifest, members: ["./skills/coordinator"] }, From bdd756daf3817fea3082b99c8afd581699d2fe4f Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 16:49:01 +0800 Subject: [PATCH 07/11] fix: validate resolved team members in every command --- src/omniskill.ts | 11 ++- tests/omniskill.test.ts | 147 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/src/omniskill.ts b/src/omniskill.ts index 521cc914..d11dcf1b 100644 --- a/src/omniskill.ts +++ b/src/omniskill.ts @@ -262,13 +262,22 @@ function configureAuthorCommands( ? { runGitCommand: options.workflowGitCommandRunner } : {}), }); + let graph: Awaited> | undefined; try { + graph = await resolveWorkflowDependencyGraph({ + bundle, + ...(options.workflowGitCommandRunner + ? { runGitCommand: options.workflowGitCommandRunner } + : {}), + installedRootDir: options.rootDir, + }); console.log( success(`Omniskills valid: ${bundle.manifest.name}@${bundle.manifest.version}`), ); console.log(keyValue("Steps", String(bundle.manifest.steps.length))); - console.log(keyValue("Skills", String(bundle.manifest.skills.length))); + console.log(keyValue("Skills", String(graph.dependencies.length))); } finally { + await graph?.cleanup?.(); await bundle.cleanup?.(); } }); diff --git a/tests/omniskill.test.ts b/tests/omniskill.test.ts index c1b588ca..e69c2f3e 100644 --- a/tests/omniskill.test.ts +++ b/tests/omniskill.test.ts @@ -46,9 +46,15 @@ function fakeSkillInstallResult(input: { async function writeGitWorkflowFixtureAt( workflowDir: string, - options: { loop?: boolean; version?: string; extraSkill?: boolean; team?: boolean } = {}, + options: { + loop?: boolean; + version?: string; + extraSkill?: boolean; + team?: boolean; + localTeamMember?: boolean; + } = {}, ): Promise { - const includeExtraSkill = options.extraSkill === true || options.team === true; + const includeExtraSkill = options.extraSkill === true; await mkdir(join(workflowDir, "skills", "git-entry"), { recursive: true }); await writeFile( join(workflowDir, "skills", "git-entry", "SKILL.md"), @@ -61,10 +67,14 @@ async function writeGitWorkflowFixtureAt( "# git-entry", ].join("\n"), ); - if (includeExtraSkill) { - await mkdir(join(workflowDir, "skills", "git-extra"), { recursive: true }); + if (includeExtraSkill || options.team) { + const extraSkillDir = + options.team && !options.localTeamMember + ? join(workflowDir, "member-workflow", "skills", "git-extra") + : join(workflowDir, "skills", "git-extra"); + await mkdir(extraSkillDir, { recursive: true }); await writeFile( - join(workflowDir, "skills", "git-extra", "SKILL.md"), + join(extraSkillDir, "SKILL.md"), [ "---", "name: git-extra", @@ -75,6 +85,23 @@ async function writeGitWorkflowFixtureAt( ].join("\n"), ); } + if (options.team && !options.localTeamMember) { + await writeFile( + join(workflowDir, "member-workflow", "workflow.json"), + JSON.stringify( + { + schemaVersion: "0.1", + name: "git-member", + version: "1.0.0", + description: "Canonical member workflow.", + skills: [{ source: "./skills/git-extra", entry: true }], + steps: [{ id: "member", title: "Member", skill: "./skills/git-extra" }], + }, + null, + 2, + ), + ); + } await writeFile( join(workflowDir, "workflow.json"), JSON.stringify( @@ -85,7 +112,7 @@ async function writeGitWorkflowFixtureAt( kind: "team", name: "git-team", coordinator: "./skills/git-entry", - members: ["./skills/git-extra"], + members: [options.localTeamMember ? "./skills/git-extra" : "./member-workflow"], } : { name: "git-workflow" }), version: options.version ?? "0.1.0", @@ -98,6 +125,9 @@ async function writeGitWorkflowFixtureAt( source: "./skills/git-entry", ...(options.loop || options.team ? { entry: true } : {}), }, + ...(options.team + ? [{ source: options.localTeamMember ? "./skills/git-extra" : "./member-workflow" }] + : []), ...(includeExtraSkill ? [{ source: "./skills/git-extra" }] : []), ], steps: [ @@ -116,6 +146,15 @@ async function writeGitWorkflowFixtureAt( }, ] : []), + ...(options.team + ? [ + { + id: "member", + title: "Member", + skill: options.localTeamMember ? "./skills/git-extra" : "./member-workflow", + }, + ] + : []), ], }, null, @@ -968,6 +1007,12 @@ describe("omniskill command module", () => { expect(output).toContain("Omniskills installed: git-team"); expect(output).toContain("Next: $git-entry"); expect(output).not.toContain("$startup-goal"); + await expect( + stat(join(homeDir, ".omniskills", "workflows", "git-team.json")), + ).resolves.toBeDefined(); + await expect( + stat(join(homeDir, ".omniskills", "workflows", "git-member.json")), + ).rejects.toThrow(); } finally { console.log = originalLog; await rm(rootDir, { recursive: true, force: true }); @@ -975,6 +1020,96 @@ describe("omniskill command module", () => { } }); + test("validate rejects a copied local team member", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "omniskill-team-validate-")); + const bundleDir = join(rootDir, "git-team"); + const program = new Command(); + try { + await writeGitWorkflowFixtureAt(bundleDir, { team: true, localTeamMember: true }); + configureOmniskillCommand(program, { + rootDir, + installSkill: async () => { + throw new Error("validate must not install skills"); + }, + printSkillInstallResult: () => {}, + }); + + await expect(program.parseAsync(["validate", bundleDir], { from: "user" })).rejects.toThrow( + "Team member must reference a child workflow", + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("validate resolves team member workflows", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "omniskill-team-validate-valid-")); + const bundleDir = join(rootDir, "git-team"); + const logs: string[] = []; + const originalLog = console.log; + const program = new Command(); + console.log = (...values: unknown[]) => logs.push(values.join(" ")); + try { + await writeGitWorkflowFixtureAt(bundleDir, { team: true }); + configureOmniskillCommand(program, { + rootDir, + installSkill: async () => { + throw new Error("validate must not install skills"); + }, + printSkillInstallResult: () => {}, + }); + + await program.parseAsync(["validate", bundleDir], { from: "user" }); + + expect(stripAnsiLines(logs)).toContain("Omniskills valid: git-team@0.1.0"); + expect(stripAnsiLines(logs)).toContain("Skills: 2"); + } finally { + console.log = originalLog; + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("all graph commands reject invalid team members before target writes", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "omniskill-team-invalid-")); + const homeDir = await mkdtemp(join(tmpdir(), "omniskill-team-invalid-home-")); + const bundleDir = join(rootDir, "git-team"); + let installCalls = 0; + try { + await writeGitWorkflowFixtureAt(bundleDir, { team: true, localTeamMember: true }); + const commandArgs = [ + ["validate", bundleDir], + ["deps", bundleDir], + ["lock", bundleDir], + ["install", bundleDir, "--home", homeDir, "--agents", "codex"], + ]; + + for (const args of commandArgs) { + const program = new Command(); + configureOmniskillCommand(program, { + rootDir, + installPrompt: { confirmInstall: async () => true }, + installSkill: async () => { + installCalls += 1; + throw new Error("installSkill must not run for an invalid graph"); + }, + printSkillInstallResult: () => {}, + }); + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow( + "Team member must reference a child workflow", + ); + } + + expect(installCalls).toBe(0); + await expect( + stat(join(homeDir, ".omniskills", "workflows", "git-team.json")), + ).rejects.toThrow(); + await expect(stat(join(bundleDir, "workflow.lock.json"))).rejects.toThrow(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + } + }); + test("install supports a workflow alias source", async () => { const rootDir = await mkdtemp(join(tmpdir(), "omniskill-alias-")); const homeDir = await mkdtemp(join(tmpdir(), "omniskill-alias-home-")); From 5dc458d023fed64c3563d0fff8abf816d099cfab Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 17:10:03 +0800 Subject: [PATCH 08/11] refactor: reuse canonical startup role workflows --- .../teams/startup-team/skills/ceo/SKILL.md | 31 -- .../teams/startup-team/skills/cto/SKILL.md | 33 -- .../skills/engineering-manager/SKILL.md | 33 -- .../skills/founding-engineer/SKILL.md | 30 -- .../skills/product-manager/SKILL.md | 33 -- .../startup-team/skills/qa-lead/SKILL.md | 32 -- .../startup-team/skills/web-design/SKILL.md | 33 -- examples/teams/startup-team/workflow.json | 102 ++---- .../teams/startup-team/workflow.lock.json | 295 +++++++++++++----- src/runtimes/omniskill/workflow-bundles.ts | 70 ++++- tests/workflow-bundles.test.ts | 164 ++++++---- 11 files changed, 388 insertions(+), 468 deletions(-) delete mode 100644 examples/teams/startup-team/skills/ceo/SKILL.md delete mode 100644 examples/teams/startup-team/skills/cto/SKILL.md delete mode 100644 examples/teams/startup-team/skills/engineering-manager/SKILL.md delete mode 100644 examples/teams/startup-team/skills/founding-engineer/SKILL.md delete mode 100644 examples/teams/startup-team/skills/product-manager/SKILL.md delete mode 100644 examples/teams/startup-team/skills/qa-lead/SKILL.md delete mode 100644 examples/teams/startup-team/skills/web-design/SKILL.md diff --git a/examples/teams/startup-team/skills/ceo/SKILL.md b/examples/teams/startup-team/skills/ceo/SKILL.md deleted file mode 100644 index 2d2eb5d5..00000000 --- a/examples/teams/startup-team/skills/ceo/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: ceo -description: "Startup-team CEO role for company direction, tradeoffs, and strategic decisions." ---- - -# CEO - -## Use When - -Use for strategy, positioning, pricing, funding, or company-level tradeoffs. - -## Companions - -- `mattpocock:wayfinder` -- `mattpocock:grill-with-docs` - -If one is unavailable, stop and name it. - -## Do - -1. State the company decision in one sentence. -2. Separate customer, market, product, technical, and funding constraints. -3. Distinguish reversible from irreversible choices. -4. Recommend the smallest evidence-gathering move. - -## Return - -- Decision: recommended company move and accepted tradeoff. -- Evidence: customer, market, funding, or strategic signal. -- Risk: company-level risk requiring owner attention. -- Handoff: decisions downstream roles must preserve or challenge. diff --git a/examples/teams/startup-team/skills/cto/SKILL.md b/examples/teams/startup-team/skills/cto/SKILL.md deleted file mode 100644 index 8e9aa06b..00000000 --- a/examples/teams/startup-team/skills/cto/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: cto -description: "Startup-team CTO role for architecture, technical risk, and engineering strategy." ---- - -# CTO - -## Use When - -Use for architecture, domain boundaries, platform direction, or technical risk. - -## Companions - -- `mattpocock:codebase-design` -- `mattpocock:domain-modeling` -- `mattpocock:diagnosing-bugs` -- `mattpocock:code-review` - -If one is unavailable, stop and name it. - -## Do - -1. Identify the decision that changes the technical trajectory. -2. Map domain language, module boundaries, and ownership. -3. Separate reversible shortcuts from debt that blocks future product work. -4. Define the next technical move and its verification gate. - -## Return - -- Decision: architecture or platform choice for this slice. -- Evidence: codebase, domain, failure, or review evidence. -- Risk: technical risk most likely to slow future work. -- Handoff: boundary and verification gate implementation must preserve. diff --git a/examples/teams/startup-team/skills/engineering-manager/SKILL.md b/examples/teams/startup-team/skills/engineering-manager/SKILL.md deleted file mode 100644 index 8d3f6245..00000000 --- a/examples/teams/startup-team/skills/engineering-manager/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: engineering-manager -description: "Startup-team engineering manager role for delivery sequencing and quality gates." ---- - -# Engineering Manager - -## Use When - -Use for delivery sequencing, ownership, blockers, or quality gates. - -## Companions - -- `superpowers:writing-plans` -- `mattpocock:tdd` -- `mattpocock:diagnosing-bugs` -- `mattpocock:code-review` - -If one is unavailable, stop and name it. - -## Do - -1. Identify the smallest shippable result and largest delivery risk. -2. Sequence work so every step leaves a verifiable repository state. -3. Assign quality gates by blast radius. -4. Escalate missing ownership, acceptance criteria, or decisions. - -## Return - -- Decision: implementation sequence for the next slice. -- Evidence: dependency, ownership, and quality-gate rationale. -- Risk: delivery blocker or ownership ambiguity. -- Handoff: ordered plan and gate for implementation and QA. diff --git a/examples/teams/startup-team/skills/founding-engineer/SKILL.md b/examples/teams/startup-team/skills/founding-engineer/SKILL.md deleted file mode 100644 index 4522bb1a..00000000 --- a/examples/teams/startup-team/skills/founding-engineer/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: founding-engineer -description: "Startup-team founding engineer role for framing an approved implementation slice and handing it to the implementation phase." ---- - -# Founding Engineer - -## Use When - -Use when the approved slice needs a precise implementation frame before execution. - -## Companions - -- `implement` for executing the approved handoff. -- `mattpocock:tdd` for selecting the test seam. - -If one is unavailable, stop and name it. - -## Do - -1. Read the plan, acceptance criteria, and local repo instructions. -2. Define the smallest correct implementation slice, affected boundaries, test seam, risks, and completion checks. -3. Do not edit files or run implementation commands; prepare the implementation frame and handoff for `implement`. - -## Return - -- Change: implementation frame prepared with no repository changes. -- Evidence: plan, acceptance criteria, affected boundaries, and test seam inspected. -- Risk: blocker, unresolved technical choice, or missing acceptance detail. -- Handoff: `implement` receives the slice, expected files, test seam, and completion checks. diff --git a/examples/teams/startup-team/skills/product-manager/SKILL.md b/examples/teams/startup-team/skills/product-manager/SKILL.md deleted file mode 100644 index 51e0741b..00000000 --- a/examples/teams/startup-team/skills/product-manager/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: product-manager -description: "Startup-team product manager role for discovery, PRDs, and issue slicing." ---- - -# Product Manager - -## Use When - -Use for customer value, product scope, acceptance criteria, or issue slicing. - -## Companions - -- `superpowers:brainstorming` -- `mattpocock:to-spec` -- `mattpocock:to-tickets` -- `superpowers:writing-plans` - -If one is unavailable, stop and name it. - -## Do - -1. Name the target user, pain, and desired behavior change. -2. Separate must-have behavior from launch polish. -3. Write acceptance criteria engineering and QA can verify. -4. Slice work so each issue creates visible product progress. - -## Return - -- Decision: must-have scope for this slice. -- Evidence: customer signal or approved product rationale. -- Risk: unresolved product or learning assumption. -- Handoff: acceptance criteria and issue boundary for delivery. diff --git a/examples/teams/startup-team/skills/qa-lead/SKILL.md b/examples/teams/startup-team/skills/qa-lead/SKILL.md deleted file mode 100644 index 14998d91..00000000 --- a/examples/teams/startup-team/skills/qa-lead/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: qa-lead -description: "Startup-team QA lead role for acceptance checks, regression risk, and release verification." ---- - -# QA Lead - -## Use When - -Use for acceptance, regression risk, and release verification. - -## Companions - -- `mattpocock:code-review` -- `mattpocock:diagnosing-bugs` -- `superpowers:verification-before-completion` - -If one is unavailable, stop and name it. - -## Do - -1. Restate the user-visible behavior that must be true. -2. Verify the highest-risk regression paths with concrete evidence. -3. Separate verified facts from residual risk. -4. Run `superpowers:verification-before-completion` before recommending release or block. - -## Return - -- Decision: release, block, or next verification step. -- Evidence: checks, reproduction steps, and command output. -- Risk: unverified behavior and why it matters. -- Handoff: release recommendation or actionable blocker. diff --git a/examples/teams/startup-team/skills/web-design/SKILL.md b/examples/teams/startup-team/skills/web-design/SKILL.md deleted file mode 100644 index d9ba2ad2..00000000 --- a/examples/teams/startup-team/skills/web-design/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: web-design -description: "Startup-team web-design role for interface direction, responsive interaction states, and animation review." ---- - -# Web Design - -## Use When - -Use for customer-facing hierarchy, responsive interaction, accessibility, or motion. - -## Companions - -- `interface-craft:design-engineering` -- `interface-craft:motion-vocabulary` -- `interface-craft:fluid-interface-design` when gestures are in scope. -- `interface-craft:motion-review` - -If one is unavailable, stop and name it. - -## Do - -1. Define the target user, job, hierarchy, states, responsive behavior, and accessibility constraints. -2. Require a user benefit, frequency, and reduced-motion behavior for every motion. -3. Name interactions before implementation and use the relevant interface-craft review. -4. Run `interface-craft:motion-review` on every changed animation; return `Before | After | Why` and an explicit **Approve** or **Block** verdict. - -## Return - -- Decision: implementable interface and motion direction. -- Evidence: hierarchy, state, responsive, accessibility, and review findings. -- Risk: unresolved usability or motion issue; **Block** is a release gate. -- Handoff: implementation requirements and QA acceptance checks. diff --git a/examples/teams/startup-team/workflow.json b/examples/teams/startup-team/workflow.json index 9ec2aad0..18a05dca 100644 --- a/examples/teams/startup-team/workflow.json +++ b/examples/teams/startup-team/workflow.json @@ -6,13 +6,13 @@ "description": "Install a startup team as one goal-oriented Omniskills bundle: a startup-goal coordinator plus CEO, CTO, product manager, web-design lead, engineering manager, founding engineer, and QA lead.", "coordinator": "./skills/startup-goal", "members": [ - "./skills/ceo", - "./skills/cto", - "./skills/product-manager", - "./skills/web-design", - "./skills/engineering-manager", - "./skills/founding-engineer", - "./skills/qa-lead" + "catalog:ceo", + "catalog:cto", + "catalog:product-manager", + "catalog:web-design", + "catalog:engineering-manager", + "catalog:founding-engineer", + "catalog:qa-lead" ], "skills": [ { @@ -20,90 +20,30 @@ "entry": true }, { - "source": "./skills/ceo" + "source": "catalog:ceo" }, { - "source": "./skills/cto" + "source": "catalog:cto" }, { - "source": "./skills/product-manager" + "source": "catalog:product-manager" }, { - "source": "./skills/web-design" + "source": "catalog:web-design" }, { - "source": "./skills/engineering-manager" + "source": "catalog:engineering-manager" }, { - "source": "./skills/founding-engineer" + "source": "catalog:founding-engineer" }, { - "source": "./skills/qa-lead" - }, - { - "source": "emilkowalski:emil-design-eng", - "repo": "emilkowalski/skills" - }, - { - "source": "emilkowalski:animation-vocabulary", - "repo": "emilkowalski/skills" - }, - { - "source": "emilkowalski:apple-design", - "repo": "emilkowalski/skills" - }, - { - "source": "emilkowalski:review-animations", - "repo": "emilkowalski/skills" + "source": "catalog:qa-lead" }, { "source": "superpowers:brainstorming", "repo": "obra/superpowers" }, - { - "source": "superpowers:writing-plans", - "repo": "obra/superpowers" - }, - { - "source": "superpowers:verification-before-completion", - "repo": "obra/superpowers" - }, - { - "source": "mattpocock:wayfinder", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:grill-with-docs", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:to-spec", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:to-tickets", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:codebase-design", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:domain-modeling", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:tdd", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:diagnosing-bugs", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, - { - "source": "mattpocock:code-review", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" - }, { "source": "mattpocock:implement", "repo": "https://github.com/mattpocock/skills/tree/v1.1.0" @@ -127,33 +67,33 @@ { "id": "strategy", "title": "Clarify company direction", - "skill": "./skills/ceo", + "skill": "catalog:ceo", "gate": "human_approval" }, { "id": "product", "title": "Shape product scope", - "skill": "./skills/product-manager" + "skill": "catalog:product-manager" }, { "id": "design", "title": "Set web interface direction and motion quality", - "skill": "./skills/web-design" + "skill": "catalog:web-design" }, { "id": "technology", "title": "Set technical direction", - "skill": "./skills/cto" + "skill": "catalog:cto" }, { "id": "delivery", "title": "Plan engineering execution", - "skill": "./skills/engineering-manager" + "skill": "catalog:engineering-manager" }, { "id": "implementation", "title": "Frame the implementation slice", - "skill": "./skills/founding-engineer" + "skill": "catalog:founding-engineer" }, { "id": "implement", @@ -163,7 +103,7 @@ { "id": "qa", "title": "Verify release readiness", - "skill": "./skills/qa-lead" + "skill": "catalog:qa-lead" } ] } diff --git a/examples/teams/startup-team/workflow.lock.json b/examples/teams/startup-team/workflow.lock.json index 707a186d..ce9ef514 100644 --- a/examples/teams/startup-team/workflow.lock.json +++ b/examples/teams/startup-team/workflow.lock.json @@ -1,105 +1,137 @@ { - "schemaVersion": "0.1", + "schemaVersion": "0.2", "workflow": "startup-team", "workflowVersion": "0.2.0", - "generatedAt": "2026-07-13T18:02:25.039Z", - "skills": [ + "generatedAt": "2026-07-14T08:56:16.104Z", + "workflows": [ { - "source": "./skills/startup-goal", - "resolvedName": "startup-goal", - "kind": "local", - "hash": "sha256:77c4e62583ebac9854618464495338793fdc604034e2824f2dc7b997d77a62c2" + "id": "workflow:startup-team@0.2.0", + "name": "startup-team", + "version": "0.2.0", + "source": { + "kind": "root" + } }, { - "source": "./skills/ceo", - "resolvedName": "ceo", - "kind": "local", - "hash": "sha256:1061ba4bcbd4c75660ab08c6f22cac59c3b0a671cf17d4d829c0211300555b64" + "id": "workflow:ceo@0.1.1", + "name": "ceo", + "version": "0.1.1", + "source": { + "kind": "git", + "url": "https://github.com/devos-ing/omni-skills.git#examples/workflows/ceo", + "commit": "45e8b05fcce1140b9ed06559bb27430d99761407", + "subdirectory": "examples/workflows/ceo" + } }, { - "source": "./skills/cto", - "resolvedName": "cto", - "kind": "local", - "hash": "sha256:25cc20e161d81e8a7589a933dcf720ddcc74469b0fea8726dfe659e31405b848" + "id": "workflow:cto@0.1.1", + "name": "cto", + "version": "0.1.1", + "source": { + "kind": "git", + "url": "https://github.com/devos-ing/omni-skills.git#examples/workflows/cto", + "commit": "45e8b05fcce1140b9ed06559bb27430d99761407", + "subdirectory": "examples/workflows/cto" + } }, { - "source": "./skills/product-manager", - "resolvedName": "product-manager", - "kind": "local", - "hash": "sha256:f1660fd825b3975c844229f70020ab6dc2e0957b1305befc20ce97c10a5b7207" + "id": "workflow:product-manager@0.1.1", + "name": "product-manager", + "version": "0.1.1", + "source": { + "kind": "git", + "url": "https://github.com/devos-ing/omni-skills.git#examples/workflows/product-manager", + "commit": "45e8b05fcce1140b9ed06559bb27430d99761407", + "subdirectory": "examples/workflows/product-manager" + } }, { - "source": "./skills/web-design", - "resolvedName": "web-design", - "kind": "local", - "hash": "sha256:67261d9b6ee94a368d85adea8113c5ddc65009fb6a120a2da6e44376e95aa21f" + "id": "workflow:web-design@0.1.0", + "name": "web-design", + "version": "0.1.0", + "source": { + "kind": "git", + "url": "https://github.com/devos-ing/omni-skills.git#examples/workflows/web-design", + "commit": "45e8b05fcce1140b9ed06559bb27430d99761407", + "subdirectory": "examples/workflows/web-design" + } }, { - "source": "./skills/engineering-manager", - "resolvedName": "engineering-manager", - "kind": "local", - "hash": "sha256:04212171f62b217d8a2a375b6df5a322ad9368b3133521d51c803f74e712ae2b" + "id": "workflow:engineering-manager@0.1.1", + "name": "engineering-manager", + "version": "0.1.1", + "source": { + "kind": "git", + "url": "https://github.com/devos-ing/omni-skills.git#examples/workflows/engineering-manager", + "commit": "45e8b05fcce1140b9ed06559bb27430d99761407", + "subdirectory": "examples/workflows/engineering-manager" + } }, { - "source": "./skills/founding-engineer", - "resolvedName": "founding-engineer", - "kind": "local", - "hash": "sha256:e14da40516ae72766e406371473f2c2e7e46589deef0c19bb23d92808d5ceb07" + "id": "workflow:founding-engineer@0.1.1", + "name": "founding-engineer", + "version": "0.1.1", + "source": { + "kind": "git", + "url": "https://github.com/devos-ing/omni-skills.git#examples/workflows/founding-engineer", + "commit": "45e8b05fcce1140b9ed06559bb27430d99761407", + "subdirectory": "examples/workflows/founding-engineer" + } }, { - "source": "./skills/qa-lead", - "resolvedName": "qa-lead", - "kind": "local", - "hash": "sha256:c6d13166af6a97fd2ef859d3414fa1e9211da153cab426cf46408fa6aa6d4af3" + "id": "workflow:qa-lead@0.1.1", + "name": "qa-lead", + "version": "0.1.1", + "source": { + "kind": "git", + "url": "https://github.com/devos-ing/omni-skills.git#examples/workflows/qa-lead", + "commit": "45e8b05fcce1140b9ed06559bb27430d99761407", + "subdirectory": "examples/workflows/qa-lead" + } + } + ], + "edges": [ + { + "from": "workflow:startup-team@0.2.0", + "to": "workflow:ceo@0.1.1" }, { - "source": "emilkowalski:emil-design-eng", - "resolvedName": "emilkowalski:emil-design-eng", - "kind": "external", - "repo": "emilkowalski/skills", - "hash": "sha256:c98e0e85967f367ece5fd23f9b2bcef62c1a9d548579472bb829464c3ebdf461" + "from": "workflow:startup-team@0.2.0", + "to": "workflow:cto@0.1.1" }, { - "source": "emilkowalski:animation-vocabulary", - "resolvedName": "emilkowalski:animation-vocabulary", - "kind": "external", - "repo": "emilkowalski/skills", - "hash": "sha256:17043916f71310aff8b03af847f4cf1280496807113072ff9fb836e64f90f133" + "from": "workflow:startup-team@0.2.0", + "to": "workflow:product-manager@0.1.1" }, { - "source": "emilkowalski:apple-design", - "resolvedName": "emilkowalski:apple-design", - "kind": "external", - "repo": "emilkowalski/skills", - "hash": "sha256:4f2d0e6e554a9b8d313ae3b32a66f9e4f1ba4c9f491e0f050ad077e100eeefa3" + "from": "workflow:startup-team@0.2.0", + "to": "workflow:web-design@0.1.0" }, { - "source": "emilkowalski:review-animations", - "resolvedName": "emilkowalski:review-animations", - "kind": "external", - "repo": "emilkowalski/skills", - "hash": "sha256:f9266e7cdefa9ceadc60558f201618755382f03b06d5ab37d2ac73bdb7807b98" + "from": "workflow:startup-team@0.2.0", + "to": "workflow:engineering-manager@0.1.1" }, { - "source": "superpowers:brainstorming", - "resolvedName": "superpowers-brainstorming", - "kind": "external", - "repo": "obra/superpowers", - "hash": "sha256:a688fac4f89e4b3481c2a2804c54872ef5186ee8ab50103b5ac9777737234387" + "from": "workflow:startup-team@0.2.0", + "to": "workflow:founding-engineer@0.1.1" }, { - "source": "superpowers:writing-plans", - "resolvedName": "superpowers-writing-plans", - "kind": "external", - "repo": "obra/superpowers", - "hash": "sha256:af085fb88831241d778d94c43b0f2b8ecdddfa250851dc08a977233d031dede6" + "from": "workflow:startup-team@0.2.0", + "to": "workflow:qa-lead@0.1.1" + } + ], + "skills": [ + { + "source": "./skills/startup-goal", + "resolvedName": "startup-goal", + "kind": "local", + "hash": "sha256:77c4e62583ebac9854618464495338793fdc604034e2824f2dc7b997d77a62c2" }, { - "source": "superpowers:verification-before-completion", - "resolvedName": "superpowers-verification-before-completion", - "kind": "external", - "repo": "obra/superpowers", - "hash": "sha256:26401a7561086d127295b4267e19ccbc44a8b0d04ca519723a8a70301ae6efdf" + "source": "workflow:ceo@0.1.1#./skills/ceo", + "resolvedName": "ceo", + "kind": "local", + "hash": "sha256:b0e1abf139bef48618da9e1727ecc4ec35d427b949771f959c4569fdaafefd19" }, { "source": "mattpocock:wayfinder", @@ -115,6 +147,53 @@ "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", "hash": "sha256:a7e2ffc2e216483f140c486c5e1b5e3e66d6ca7cf47980357069b21887645069" }, + { + "source": "workflow:cto@0.1.1#./skills/cto", + "resolvedName": "cto", + "kind": "local", + "hash": "sha256:537d4753b34622b969200709611d0751263e807a0500f02a1b35a5157b60320b" + }, + { + "source": "mattpocock:codebase-design", + "resolvedName": "codebase-design", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:452bfb044551a8177528b7df68e204e6a77b8a7f89daa741ef659f652c441376" + }, + { + "source": "mattpocock:domain-modeling", + "resolvedName": "domain-modeling", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:f01e41e3fdaf3c4d4c20ddea9eb00e8330003d91bb564ef4518aaee856d4bf69" + }, + { + "source": "mattpocock:diagnosing-bugs", + "resolvedName": "diagnosing-bugs", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:8148a4181f76442579999629563f08f31fa3784955584e781a190b64d65a1501" + }, + { + "source": "mattpocock:code-review", + "resolvedName": "code-review", + "kind": "external", + "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", + "hash": "sha256:913b01e36a6d3e28173b68e3147295754dd1ff2b91dbc23ebb1161cc687c91e6" + }, + { + "source": "workflow:product-manager@0.1.1#./skills/product-manager", + "resolvedName": "product-manager", + "kind": "local", + "hash": "sha256:4d0db8b9f5e72b0b8708685ad2d4998373454719efea402f60f81fc1ca877970" + }, + { + "source": "superpowers:brainstorming", + "resolvedName": "superpowers-brainstorming", + "kind": "external", + "repo": "obra/superpowers", + "hash": "sha256:a688fac4f89e4b3481c2a2804c54872ef5186ee8ab50103b5ac9777737234387" + }, { "source": "mattpocock:to-spec", "resolvedName": "to-spec", @@ -130,18 +209,51 @@ "hash": "sha256:49679c184e8970591136c431ba4fa00a4890aff23a2701c194465f79007a23c9" }, { - "source": "mattpocock:codebase-design", - "resolvedName": "codebase-design", + "source": "superpowers:writing-plans", + "resolvedName": "superpowers-writing-plans", "kind": "external", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", - "hash": "sha256:452bfb044551a8177528b7df68e204e6a77b8a7f89daa741ef659f652c441376" + "repo": "obra/superpowers", + "hash": "sha256:af085fb88831241d778d94c43b0f2b8ecdddfa250851dc08a977233d031dede6" }, { - "source": "mattpocock:domain-modeling", - "resolvedName": "domain-modeling", + "source": "workflow:web-design@0.1.0#./skills/web-design", + "resolvedName": "web-design", + "kind": "local", + "hash": "sha256:5cd02b629f13409c04a8f28f92909aeae7ca7d392eba6b95587876b662814f88" + }, + { + "source": "emilkowalski:emil-design-eng", + "resolvedName": "emilkowalski:emil-design-eng", "kind": "external", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", - "hash": "sha256:f01e41e3fdaf3c4d4c20ddea9eb00e8330003d91bb564ef4518aaee856d4bf69" + "repo": "emilkowalski/skills", + "hash": "sha256:c98e0e85967f367ece5fd23f9b2bcef62c1a9d548579472bb829464c3ebdf461" + }, + { + "source": "emilkowalski:animation-vocabulary", + "resolvedName": "emilkowalski:animation-vocabulary", + "kind": "external", + "repo": "emilkowalski/skills", + "hash": "sha256:17043916f71310aff8b03af847f4cf1280496807113072ff9fb836e64f90f133" + }, + { + "source": "emilkowalski:apple-design", + "resolvedName": "emilkowalski:apple-design", + "kind": "external", + "repo": "emilkowalski/skills", + "hash": "sha256:4f2d0e6e554a9b8d313ae3b32a66f9e4f1ba4c9f491e0f050ad077e100eeefa3" + }, + { + "source": "emilkowalski:review-animations", + "resolvedName": "emilkowalski:review-animations", + "kind": "external", + "repo": "emilkowalski/skills", + "hash": "sha256:f9266e7cdefa9ceadc60558f201618755382f03b06d5ab37d2ac73bdb7807b98" + }, + { + "source": "workflow:engineering-manager@0.1.1#./skills/engineering-manager", + "resolvedName": "engineering-manager", + "kind": "local", + "hash": "sha256:83e32c2d3a29d869085cb4735a6c1c8259e1b68acb6a92ddc2f42d9c331a8291" }, { "source": "mattpocock:tdd", @@ -151,18 +263,29 @@ "hash": "sha256:e845c35d776277cb08cbf3b68ea32b28e181069620c4a6b9d9642871184105f8" }, { - "source": "mattpocock:diagnosing-bugs", - "resolvedName": "diagnosing-bugs", + "source": "workflow:founding-engineer@0.1.1#./skills/founding-engineer", + "resolvedName": "founding-engineer", + "kind": "local", + "hash": "sha256:31849f572dc09e4df212fe66981a04cb67749364441501526d0c48ebb0175490" + }, + { + "source": "implement", + "resolvedName": "implement", "kind": "external", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", - "hash": "sha256:8148a4181f76442579999629563f08f31fa3784955584e781a190b64d65a1501" + "hash": "sha256:16f51d5ecc0b385c98451eb9da8edc19bf16bd27d7c65321abdd26592b1df311" }, { - "source": "mattpocock:code-review", - "resolvedName": "code-review", + "source": "superpowers:verification-before-completion", + "resolvedName": "superpowers-verification-before-completion", "kind": "external", - "repo": "https://github.com/mattpocock/skills/tree/v1.1.0", - "hash": "sha256:913b01e36a6d3e28173b68e3147295754dd1ff2b91dbc23ebb1161cc687c91e6" + "repo": "obra/superpowers", + "hash": "sha256:26401a7561086d127295b4267e19ccbc44a8b0d04ca519723a8a70301ae6efdf" + }, + { + "source": "workflow:qa-lead@0.1.1#./skills/qa-lead", + "resolvedName": "qa-lead", + "kind": "local", + "hash": "sha256:a9c4e9f241b612ab0c4302bb847b5c0232c32210b599b616c6c388f3b899b647" }, { "source": "mattpocock:implement", diff --git a/src/runtimes/omniskill/workflow-bundles.ts b/src/runtimes/omniskill/workflow-bundles.ts index 1e19d6c4..46a26732 100644 --- a/src/runtimes/omniskill/workflow-bundles.ts +++ b/src/runtimes/omniskill/workflow-bundles.ts @@ -364,6 +364,7 @@ export interface WorkflowDependencyGraphEdge { export interface WorkflowDependencyGraph { dependencies: WorkflowSkillInstallDependency[]; + lockSources: string[]; displaySources: string[]; workflows: WorkflowDependencyGraphWorkflow[]; edges: WorkflowDependencyGraphEdge[]; @@ -533,7 +534,11 @@ export async function createWorkflowLockFile( generatedAt, workflows: createLockedWorkflowGraph(bundle.sourceDir, graph.workflows), edges: graph.edges, - skills: await createWorkflowLockSkillEntries(bundle.sourceDir, graph.dependencies), + skills: await createWorkflowLockSkillEntries( + bundle.sourceDir, + graph.dependencies, + graph.lockSources, + ), }); } finally { await graph.cleanup?.(); @@ -862,7 +867,10 @@ export async function resolveWorkflowDependencyGraph(input: { ); } - const selectedDependencies = new Map(); + const selectedDependencies = new Map< + string, + { dependency: WorkflowSkillInstallDependency; lockSource: string } + >(); const dependencyOrder: string[] = []; const workflows: WorkflowDependencyGraphWorkflow[] = []; const edges: WorkflowDependencyGraphEdge[] = []; @@ -914,12 +922,17 @@ export async function resolveWorkflowDependencyGraph(input: { } const dependencyId = dependency.source; - const selectedDependency = selectedDependencies.get(dependencyId); - if (!selectedDependency) { + const selected = selectedDependencies.get(dependencyId); + const lockSource = getResolvedWorkflowSkillLockSource( + bundle, + dependency, + bundle === input.bundle, + ); + if (!selected) { dependencyOrder.push(dependencyId); - selectedDependencies.set(dependencyId, dependency); - } else if (isPreferredWorkflowSkillDependency(dependency, selectedDependency)) { - selectedDependencies.set(dependencyId, dependency); + selectedDependencies.set(dependencyId, { dependency, lockSource }); + } else if (isPreferredWorkflowSkillDependency(dependency, selected.dependency)) { + selectedDependencies.set(dependencyId, { dependency, lockSource }); } } }; @@ -931,10 +944,12 @@ export async function resolveWorkflowDependencyGraph(input: { throw error; } - const dependencies = dependencyOrder.flatMap((dependencyId) => { - const dependency = selectedDependencies.get(dependencyId); - return dependency ? [dependency] : []; + const selectedDependencyList = dependencyOrder.flatMap((dependencyId) => { + const selected = selectedDependencies.get(dependencyId); + return selected ? [selected] : []; }); + const dependencies = selectedDependencyList.map(({ dependency }) => dependency); + const lockSources = selectedDependencyList.map(({ lockSource }) => lockSource); if (!input.ignoreLockValidation && input.bundle.lock?.schemaVersion === "0.2") { try { @@ -944,6 +959,7 @@ export async function resolveWorkflowDependencyGraph(input: { workflows, edges, dependencies, + lockSources, }); } catch (error) { await Promise.allSettled(cleanups.map((cleanup) => cleanup())); @@ -953,6 +969,7 @@ export async function resolveWorkflowDependencyGraph(input: { return { dependencies, + lockSources, displaySources: getWorkflowDependencyDisplaySources(input.bundle, workflows, dependencies), workflows, edges, @@ -962,6 +979,20 @@ export async function resolveWorkflowDependencyGraph(input: { }; } +function getResolvedWorkflowSkillLockSource( + bundle: WorkflowBundle, + dependency: WorkflowSkillInstallDependency, + isRoot: boolean, +): string { + if (isRoot || bundle.source.kind !== "git" || !isAbsolute(dependency.source)) { + return dependency.source; + } + return `${getDisplayWorkflowIdentity(bundle)}#${getPortableLocalLockSource( + bundle.sourceDir, + dependency.source, + )}`; +} + function isPreferredWorkflowSkillDependency( candidate: WorkflowSkillInstallDependency, selected: WorkflowSkillInstallDependency, @@ -1536,13 +1567,18 @@ async function validateResolvedTransitiveWorkflowLock(input: { workflows: WorkflowDependencyGraphWorkflow[]; edges: WorkflowDependencyGraphEdge[]; dependencies: WorkflowSkillInstallDependency[]; + lockSources: string[]; }): Promise { const resolvedWorkflows = createLockedWorkflowGraph(input.rootDir, input.workflows); const lockedWorkflows = input.lock.workflows.map((workflow, index) => ({ ...workflow, source: index === 0 ? validateLockedRootSource(workflow.source) : workflow.source, })); - const resolvedSkills = await createWorkflowLockSkillEntries(input.rootDir, input.dependencies); + const resolvedSkills = await createWorkflowLockSkillEntries( + input.rootDir, + input.dependencies, + input.lockSources, + ); const matches = JSON.stringify(lockedWorkflows) === JSON.stringify(resolvedWorkflows) && JSON.stringify(input.lock.edges) === JSON.stringify(input.edges) && @@ -1590,14 +1626,20 @@ function getPortableLockedWorkflowSource( async function createWorkflowLockSkillEntries( rootDir: string, dependencies: WorkflowSkillInstallDependency[], + lockSources?: string[], ): Promise { return Promise.all( - dependencies.map(async (skill) => { + dependencies.map(async (skill, index) => { const isLocal = isAbsolute(skill.source); - const lockSource = isLocal ? getPortableLocalLockSource(rootDir, skill.source) : skill.source; + const selectedLockSource = lockSources?.[index]; + const lockSource = + selectedLockSource && isAbsolute(selectedLockSource) + ? getPortableLocalLockSource(rootDir, selectedLockSource) + : (selectedLockSource ?? + (isLocal ? getPortableLocalLockSource(rootDir, skill.source) : skill.source)); return { source: lockSource, - resolvedName: getWorkflowLockResolvedName(lockSource), + resolvedName: isLocal ? basename(skill.source) : getWorkflowLockResolvedName(lockSource), kind: isLocal ? "local" : "external", ...(skill.repo ? { repo: skill.repo } : {}), hash: isLocal diff --git a/tests/workflow-bundles.test.ts b/tests/workflow-bundles.test.ts index 5115b0c9..bc1cae17 100644 --- a/tests/workflow-bundles.test.ts +++ b/tests/workflow-bundles.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -28,22 +28,22 @@ import { } from "../src/runtimes/omniskill/workflow-bundles"; const startupRoleContracts = [ - { role: "ceo", phrases: ["State the company decision", "smallest evidence-gathering move"] }, + { role: "ceo", phrases: ["company-level decision", "smallest evidence-gathering step"] }, { role: "product-manager", phrases: ["Write acceptance criteria", "visible product progress"] }, { role: "cto", phrases: ["technical trajectory", "verification gate"] }, { role: "engineering-manager", - phrases: ["smallest shippable result", "verifiable repository state"], + phrases: ["smallest shippable outcome", "verifiable state"], }, { role: "founding-engineer", - phrases: ["smallest correct implementation slice", "implementation frame and handoff"], + phrases: ["smallest correct change", "smallest useful verification loop"], }, - { role: "qa-lead", phrases: ["Restate the user-visible behavior", "Separate verified facts"] }, + { role: "qa-lead", phrases: ["Restate the user-facing behavior", "Separate verified facts"] }, { role: "web-design", phrases: [ - "interface-craft:motion-review` on every changed animation", + "Review all changed animation with `interface-craft:motion-review`", "Before | After | Why", "**Approve** or **Block**", ], @@ -52,7 +52,7 @@ const startupRoleContracts = [ const readStartupRoleSkill = (role: string) => readFile( - join(import.meta.dir, "..", "examples", "teams", "startup-team", "skills", role, "SKILL.md"), + join(import.meta.dir, "..", "examples", "workflows", role, "skills", role, "SKILL.md"), "utf8", ); @@ -683,8 +683,8 @@ describe("workflow bundles", () => { ); try { - const graph = await resolveWorkflowDependencyGraph({ - bundle: await loadWorkflowBundle(parentDir), + const lock = await createWorkflowLockFile(await loadWorkflowBundle(parentDir), { + generatedAt: "2026-07-14T00:00:00.000Z", runGitCommand: async (command) => { commands.push(command); if (command.args[0] === "clone") { @@ -710,14 +710,19 @@ describe("workflow bundles", () => { }); expect(commands.map((command) => command.args[0])).toEqual(["clone", "rev-parse"]); - expect(graph.workflows[1]).toMatchObject({ + expect(lock.schemaVersion).toBe("0.2"); + if (lock.schemaVersion !== "0.2") { + throw new Error("Expected a transitive lock"); + } + expect(lock.workflows[1]).toMatchObject({ name: "child", version: "2.0.0", source: { kind: "git", url: source, commit: "abc123", subdirectory: "packages/child" }, }); - expect(graph.dependencies).toHaveLength(2); - expect(graph.dependencies[1]?.source).toContain("packages/child/skills/child"); - await graph.cleanup?.(); + expect(lock.skills).toHaveLength(2); + expect(lock.skills[0]?.source).toBe("./skills/coordinator"); + expect(lock.skills[1]?.source).toBe("workflow:child@2.0.0#./skills/child"); + expect(lock.skills[1]?.resolvedName).toBe("child"); } finally { await rm(rootDir, { recursive: true, force: true }); } @@ -1322,9 +1327,23 @@ describe("workflow bundles", () => { ); expect(startupTeam.manifest.name).toBe("startup-team"); expect(startupTeam.lock?.workflow).toBe("startup-team"); - expect(startupTeam.lock?.skills.map((skill) => skill.source)).toEqual( - startupTeam.manifest.skills.map((skill) => skill.source), - ); + expect(startupTeam.lock?.schemaVersion).toBe("0.2"); + if (startupTeam.lock?.schemaVersion === "0.2") { + expect(startupTeam.lock.workflows.map(({ name }) => name)).toEqual([ + "startup-team", + "ceo", + "cto", + "product-manager", + "web-design", + "engineering-manager", + "founding-engineer", + "qa-lead", + ]); + expect(startupTeam.lock.edges).toHaveLength(7); + expect(startupTeam.lock.skills.map((skill) => skill.source)).toContain( + "workflow:ceo@0.1.1#./skills/ceo", + ); + } expect(startupTeam.manifest.skills.map((skill) => skill.source)).not.toContain("pony-trail"); }); @@ -1422,6 +1441,15 @@ describe("workflow bundles", () => { }); test("startup team entry skill dispatches role subagents and combines results", async () => { + const canonicalMembers = [ + "catalog:ceo", + "catalog:cto", + "catalog:product-manager", + "catalog:web-design", + "catalog:engineering-manager", + "catalog:founding-engineer", + "catalog:qa-lead", + ]; const bundle = await loadWorkflowBundle( join(import.meta.dir, "..", "examples", "teams", "startup-team"), ); @@ -1444,15 +1472,7 @@ describe("workflow bundles", () => { name: "startup-team", version: "0.2.0", coordinator: "./skills/startup-goal", - members: [ - "./skills/ceo", - "./skills/cto", - "./skills/product-manager", - "./skills/web-design", - "./skills/engineering-manager", - "./skills/founding-engineer", - "./skills/qa-lead", - ], + members: canonicalMembers, }); expect( bundle.manifest.skills.find((candidate) => candidate.source === bundle.manifest.coordinator), @@ -1463,32 +1483,57 @@ describe("workflow bundles", () => { expect(bundle.manifest.steps.map((step) => [step.id, step.skill, step.gate ?? null])).toEqual([ ["requirements", "superpowers:brainstorming", "human_approval"], ["route", "./skills/startup-goal", "human_approval"], - ["strategy", "./skills/ceo", "human_approval"], - ["product", "./skills/product-manager", null], - ["design", "./skills/web-design", null], - ["technology", "./skills/cto", null], - ["delivery", "./skills/engineering-manager", null], - ["implementation", "./skills/founding-engineer", null], + ["strategy", "catalog:ceo", "human_approval"], + ["product", "catalog:product-manager", null], + ["design", "catalog:web-design", null], + ["technology", "catalog:cto", null], + ["delivery", "catalog:engineering-manager", null], + ["implementation", "catalog:founding-engineer", null], ["implement", "mattpocock:implement", null], - ["qa", "./skills/qa-lead", null], + ["qa", "catalog:qa-lead", null], ]); expect(bundle.manifest.steps[0]?.instruction).toContain( "Interview the user one question at a time", ); - expect(bundle.manifest.skills).toEqual( - expect.arrayContaining([ - { source: "./skills/web-design" }, - { source: "emilkowalski:emil-design-eng", repo: "emilkowalski/skills" }, - { source: "emilkowalski:animation-vocabulary", repo: "emilkowalski/skills" }, - { source: "emilkowalski:apple-design", repo: "emilkowalski/skills" }, - { source: "emilkowalski:review-animations", repo: "emilkowalski/skills" }, - { - source: "mattpocock:implement", - repo: "https://github.com/mattpocock/skills/tree/v1.1.0", - }, - ]), - ); + expect(bundle.manifest.skills).toEqual([ + { source: "./skills/startup-goal", entry: true }, + ...canonicalMembers.map((source) => ({ source })), + { source: "superpowers:brainstorming", repo: "obra/superpowers" }, + { + source: "mattpocock:implement", + repo: "https://github.com/mattpocock/skills/tree/v1.1.0", + }, + ]); expect(bundle.manifest.skills).not.toContainEqual({ source: "implement" }); + + const graph = await resolveWorkflowDependencyGraph({ + bundle, + ignoreLockValidation: true, + runGitCommand: async (command) => { + if (command.args[0] === "clone") { + const checkoutDir = command.args.at(-1) ?? ""; + await cp(join(import.meta.dir, "..", "examples"), join(checkoutDir, "examples"), { + recursive: true, + }); + return { stdout: "", stderr: "", exitCode: 0 }; + } + return { stdout: "fixture-commit\n", stderr: "", exitCode: 0 }; + }, + }); + expect(graph.workflows.map(({ name }) => name)).toEqual([ + "startup-team", + ...canonicalMembers.map((source) => source.slice("catalog:".length)), + ]); + expect(graph.edges).toHaveLength(7); + for (const role of canonicalMembers.map((source) => source.slice("catalog:".length))) { + expect( + graph.dependencies.filter(({ source }) => source.endsWith(`/skills/${role}`)), + ).toHaveLength(1); + await expect( + stat(join(import.meta.dir, "..", "examples", "teams", "startup-team", "skills", role)), + ).rejects.toThrow(); + } + await graph.cleanup?.(); expect(skill).toContain("name: startup-goal"); for (const heading of [ "## 1. Clarify", @@ -1516,24 +1561,20 @@ describe("workflow bundles", () => { for (const { role } of startupRoleContracts) { const roleSkill = await readStartupRoleSkill(role); - for (const contract of ["## Use When", "## Companions", "## Do", "## Return"]) { + for (const contract of ["## Required Companion Skills", "## Operating Mode"]) { expect(roleSkill).toContain(contract); } - expect(roleSkill).toMatch(/- (Decision|Change):/); - for (const field of ["Evidence", "Risk", "Handoff"]) { - expect(roleSkill).toContain(`- ${field}:`); - } } }); - test("startup team bundled role skills define role-specific operating modes", async () => { + test("canonical startup role skills define role-specific operating modes", async () => { for (const contract of startupRoleContracts) { const skill = await readStartupRoleSkill(contract.role); expect(skill).toContain(`name: ${contract.role}`); - expect(skill).toContain("## Companions"); - expect(skill).toContain("If one is unavailable, stop and name it."); - expect(skill).toContain("## Do"); + expect(skill).toContain("## Required Companion Skills"); + expect(skill).toContain("If a companion skill is unavailable"); + expect(skill).toContain("## Operating Mode"); for (const phrase of contract.phrases) { expect(skill).toContain(phrase); } @@ -1544,17 +1585,16 @@ describe("workflow bundles", () => { const skill = await readStartupRoleSkill("founding-engineer"); for (const phrase of [ - "smallest correct implementation slice", - "affected boundaries", - "test seam", - "risks", - "completion checks", - "implementation frame and handoff", + "smallest correct change", + "focused tests", + "smallest useful verification loop", + "Debug from evidence", + "commands run", ]) { expect(skill).toContain(phrase); } - expect(skill).toContain("Do not edit files or run implementation commands"); - expect(skill).not.toContain("Implement the smallest correct change"); + expect(skill).toContain("before editing"); + expect(skill).not.toContain("Do not edit files or run implementation commands"); }); test("haaland workflow stays one-step and unconditional", async () => { From bba5e4d8f0aa762bd192a645e76fcd23566ed1ed Mon Sep 17 00:00:00 2001 From: Workflow Test Date: Tue, 14 Jul 2026 17:10:34 +0800 Subject: [PATCH 09/11] docs: link startup team to canonical role workflows --- AGENTS.md | 7 +++-- docs/architecture.md | 16 ++++++----- docs/workflow-author-guide.md | 6 ++++ landing/app/workflows/[slug]/page.tsx | 6 ++-- landing/components/workflow-detail.tsx | 6 ++-- landing/components/workflow-run-demo.tsx | 12 +++++--- landing/lib/landing-content.ts | 27 ++++++++++-------- landing/lib/skill-hub.ts | 6 ++-- tests/landing-app.test.ts | 35 ++++++++++++++++++------ tests/landing-skill-hub.test.ts | 30 ++++++++++++++++---- tests/readme.test.ts | 13 +++++---- 11 files changed, 110 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c4d1a26..5eb69707 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,11 @@ Author or user request - An Omniskills bundle is a deployable skills set with a `workflow.json`, optional `workflow.lock.json`, README, and optional local skills. Omitted - `kind` means workflow; first-class teams declare `kind: "team"` plus a local - declared `coordinator` and unique local declared `members`. + `kind` means workflow. A first-class team declares `kind: "team"`; its + `coordinator` is one declared local entry skill, and every `members[]` source + must be declared in `skills[]` and resolve to a child workflow with exactly + one local entry skill. Child dependencies expand recursively, while only the + root team install record is written. - `startup-team` under `examples/teams/startup-team` is the primary public team. Role workflows such as `cto`, `product-manager`, and `founding-engineer` remain independently installable public examples. diff --git a/docs/architecture.md b/docs/architecture.md index a40f4424..fde44f08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,8 +52,8 @@ Compatibility aliases: - parse and validate `workflow.json` - preserve omitted `kind` as a legacy workflow and validate first-class - `kind: "team"` manifests with a local declared `coordinator`, unique local - declared `members`, and no coordinator/member overlap + `kind: "team"` manifests with one local entry `coordinator`, unique declared + `members` that resolve to child workflows, and no coordinator/member overlap - parse and validate optional `workflow.lock.json` - generate deterministic skill fingerprints for local and external skill sources through `omniskill lock ` @@ -136,11 +136,13 @@ should stay aligned with `workflow.json`. A first-class team uses the same file layout under `examples/teams/` and declares `kind: "team"`, `coordinator`, and `members` in `workflow.json`. -`coordinator` and every `members[]` value must match a declared local -`skills[].source`. For `startup-team`, the coordinator is marked as the callable -entry skill: startup-goal remains the callable coordinator while the seven -startup role skills are members. The public bundle installs as `startup-team`; -users invoke `$startup-goal` after installation. +The coordinator is one declared local entry skill. Every `members[]` source +must be declared in `skills[]` and resolve to a child workflow with exactly one +local entry skill. Member dependencies expand recursively, but only the root +team install record is written. For `startup-team`, startup-goal remains the +callable coordinator while seven canonical standalone role workflows provide +the members. The public bundle installs as `startup-team`; users invoke +`$startup-goal` after installation. `workflow.lock.json` is generated by `omniskill lock `. It records deterministic hashes for local skill files and stable fingerprints for external diff --git a/docs/workflow-author-guide.md b/docs/workflow-author-guide.md index 5b2e4578..46df35b5 100644 --- a/docs/workflow-author-guide.md +++ b/docs/workflow-author-guide.md @@ -418,6 +418,12 @@ Install `startup-team` when you want the full role bench. Invoke its bun run dev -- install examples/teams/startup-team ``` +A team coordinator is one declared local entry skill. Every `members[]` source +must be declared in `skills[]` and resolve to a child workflow with exactly one +local entry skill. Member workflow dependencies are expanded recursively; only +the root team install record is written. Local helper skills may still be +declared, but they are not team members. + ## Compatibility Example: Combine RTK, Ponytrail, Superpowers, And Matt Pocock Skills The checked-in `real-engineering` workflow shows how to combine local workflow diff --git a/landing/app/workflows/[slug]/page.tsx b/landing/app/workflows/[slug]/page.tsx index f29e5b55..882d8b69 100644 --- a/landing/app/workflows/[slug]/page.tsx +++ b/landing/app/workflows/[slug]/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { CopyableInstallCommand } from "../../../components/copyable-install-command"; import { WorkflowAvatar } from "../../../components/workflow-avatar"; -import { catalogEntries, getLocalSkillSourceUrl } from "../../../lib/landing-content"; +import { catalogEntries, getSkillSourceUrl } from "../../../lib/landing-content"; interface WorkflowPageProps { params: Promise<{ @@ -25,7 +25,7 @@ export default async function WorkflowPage({ params }: WorkflowPageProps) { notFound(); } - const entrySkillSourceUrl = getLocalSkillSourceUrl(workflow, workflow.entrySkill); + const entrySkillSourceUrl = getSkillSourceUrl(workflow, workflow.entrySkill); return (
@@ -103,7 +103,7 @@ export default async function WorkflowPage({ params }: WorkflowPageProps) {
{workflow.diagramSteps.map((step, index) => { - const skillSourceUrl = getLocalSkillSourceUrl(workflow, step.skill); + const skillSourceUrl = getSkillSourceUrl(workflow, step.skill); return (
diff --git a/landing/components/workflow-detail.tsx b/landing/components/workflow-detail.tsx index 80089ca4..e8687806 100644 --- a/landing/components/workflow-detail.tsx +++ b/landing/components/workflow-detail.tsx @@ -1,5 +1,5 @@ import { ArrowRight, ExternalLink, GitBranch, Zap } from "lucide-react"; -import { getLocalSkillSourceUrl, type WorkflowCardContent } from "../lib/landing-content"; +import { getSkillSourceUrl, type WorkflowCardContent } from "../lib/landing-content"; import { WorkflowAvatar } from "./workflow-avatar"; interface WorkflowDetailProps { @@ -7,7 +7,7 @@ interface WorkflowDetailProps { } export function WorkflowDetail({ workflow }: WorkflowDetailProps) { - const entrySkillSourceUrl = getLocalSkillSourceUrl(workflow, workflow.entrySkill); + const entrySkillSourceUrl = getSkillSourceUrl(workflow, workflow.entrySkill); return (